From aaf7dc68e84b8b0f06481d28bac4b0216c003948 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:23:54 +0000 Subject: [PATCH 01/20] CLI: Update Go SDK to 820e4fed1c2963e3dea2b6159640c4d855f82cde SDK version bump only. A full enumeration of SDK methods vs CLI commands found no coverage gaps (config-registry endpoints are x-cli-skip). Tested: go build ./... (no new commands/flags to smoke test) Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2b6c3ff2..cf1c13b4 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.114.1-0.20260930182635-e746d9980b83 + github.com/kernel/kernel-go-sdk v0.115.1-0.20261001192137-820e4fed1c29 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 f361cd73..629914e6 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.114.1-0.20260930182635-e746d9980b83 h1:S0qtghU55P043VAtKzRzoPM9Ix8OcsEbTQNGsnoWN3U= -github.com/kernel/kernel-go-sdk v0.114.1-0.20260930182635-e746d9980b83/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.115.1-0.20261001192137-820e4fed1c29 h1:ioqhtHfJqNKP9u3Tvd/uyfKJnlkEP/Go0w8VEbRwl6c= +github.com/kernel/kernel-go-sdk v0.115.1-0.20261001192137-820e4fed1c29/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 51e68e018912e0a74182c908537eca5e23b80c28 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:00:13 +0000 Subject: [PATCH 02/20] CLI: Update SDK to 10c4031082d7 and add credential TOTP flags - Bump github.com/kernel/kernel-go-sdk to 10c4031082d73b41180adeb54a722f89341907ff - Add --totp-algorithm, --totp-digits, --totp-period to `kernel credentials create` and `kernel credentials update` (CreateCredentialRequestParam / UpdateCredentialRequestParam TotpAlgorithm, TotpDigits, TotpPeriod) - Show TOTP algorithm/digits/period in credentials get/create output Tested: credentials create --totp-secret --totp-algorithm sha256 --totp-digits 8 --totp-period 60 (8-digit code returned, metadata shown in get), credentials totp-code, credentials update --totp-secret --totp-algorithm SHA512 --totp-digits 7 (verified in get -o json), invalid --totp-algorithm rejected, otpauth:// URI params take precedence over explicit flags, credentials delete cleanup. Co-Authored-By: Claude Opus 5.5 --- cmd/credentials.go | 121 ++++++++++++++++++++++++++++++++++------ cmd/credentials_test.go | 19 +++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 126 insertions(+), 20 deletions(-) create mode 100644 cmd/credentials_test.go diff --git a/cmd/credentials.go b/cmd/credentials.go index bf7b764c..d1024c70 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -44,12 +44,15 @@ type CredentialsGetInput struct { } type CredentialsCreateInput struct { - Name string - Domain string - Values map[string]string - SSOProvider string - TotpSecret string - Output string + Name string + Domain string + Values map[string]string + SSOProvider string + TotpSecret string + TotpAlgorithm string + TotpDigits Int64Flag + TotpPeriod Int64Flag + Output string } type CredentialsUpdateInput struct { @@ -57,6 +60,9 @@ type CredentialsUpdateInput struct { Name string SSOProvider string TotpSecret string + TotpAlgorithm string + TotpDigits Int64Flag + TotpPeriod Int64Flag Values map[string]string RemoveValueKeys []string Output string @@ -167,10 +173,13 @@ func (c CredentialsCmd) Get(ctx context.Context, in CredentialsGetInput) error { {"Name", cred.Name}, {"Domain", cred.Domain}, {"Has TOTP Secret", hasTOTP}, + } + tableData = append(tableData, credentialTotpRows(cred)...) + tableData = append(tableData, pterm.TableData{ {"SSO Provider", ssoProvider}, {"Created At", util.FormatLocal(cred.CreatedAt)}, {"Updated At", util.FormatLocal(cred.UpdatedAt)}, - } + }...) PrintTableNoPad(tableData, true) return nil @@ -204,6 +213,19 @@ func (c CredentialsCmd) Create(ctx context.Context, in CredentialsCreateInput) e if in.TotpSecret != "" { params.CreateCredentialRequest.TotpSecret = kernel.Opt(in.TotpSecret) } + if in.TotpAlgorithm != "" { + algorithm, err := normalizeTotpAlgorithm(in.TotpAlgorithm) + if err != nil { + return err + } + params.CreateCredentialRequest.TotpAlgorithm = kernel.CreateCredentialRequestTotpAlgorithm(algorithm) + } + if in.TotpDigits.Set { + params.CreateCredentialRequest.TotpDigits = kernel.Opt(in.TotpDigits.Value) + } + if in.TotpPeriod.Set { + params.CreateCredentialRequest.TotpPeriod = kernel.Opt(in.TotpPeriod.Value) + } if in.Output != "json" { pterm.Info.Printf("Creating credential '%s'...\n", in.Name) @@ -235,8 +257,9 @@ func (c CredentialsCmd) Create(ctx context.Context, in CredentialsCreateInput) e {"Name", cred.Name}, {"Domain", cred.Domain}, {"Has TOTP Secret", hasTOTP}, - {"SSO Provider", ssoProvider}, } + tableData = append(tableData, credentialTotpRows(cred)...) + tableData = append(tableData, []string{"SSO Provider", ssoProvider}) PrintTableNoPad(tableData, true) @@ -248,6 +271,36 @@ func (c CredentialsCmd) Create(ctx context.Context, in CredentialsCreateInput) e return nil } +// normalizeTotpAlgorithm validates a TOTP HMAC algorithm and returns its +// canonical upper-case form (SHA1, SHA256, or SHA512). +func normalizeTotpAlgorithm(algorithm string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(algorithm)) + switch normalized { + case "SHA1", "SHA256", "SHA512": + return normalized, nil + default: + return "", fmt.Errorf("invalid --totp-algorithm %q (must be one of SHA1, SHA256, SHA512)", algorithm) + } +} + +// credentialTotpRows returns TOTP metadata rows for credentials with a TOTP secret. +func credentialTotpRows(cred *kernel.Credential) pterm.TableData { + if !cred.HasTotpSecret { + return nil + } + rows := pterm.TableData{} + if cred.TotpAlgorithm != "" { + rows = append(rows, []string{"TOTP Algorithm", string(cred.TotpAlgorithm)}) + } + if cred.TotpDigits > 0 { + rows = append(rows, []string{"TOTP Digits", fmt.Sprintf("%d", cred.TotpDigits)}) + } + if cred.TotpPeriod > 0 { + rows = append(rows, []string{"TOTP Period", fmt.Sprintf("%ds", cred.TotpPeriod)}) + } + return rows +} + func (c CredentialsCmd) Update(ctx context.Context, in CredentialsUpdateInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -265,6 +318,19 @@ func (c CredentialsCmd) Update(ctx context.Context, in CredentialsUpdateInput) e if in.TotpSecret != "" { params.UpdateCredentialRequest.TotpSecret = kernel.Opt(in.TotpSecret) } + if in.TotpAlgorithm != "" { + algorithm, err := normalizeTotpAlgorithm(in.TotpAlgorithm) + if err != nil { + return err + } + params.UpdateCredentialRequest.TotpAlgorithm = kernel.UpdateCredentialRequestTotpAlgorithm(algorithm) + } + if in.TotpDigits.Set { + params.UpdateCredentialRequest.TotpDigits = kernel.Opt(in.TotpDigits.Value) + } + if in.TotpPeriod.Set { + params.UpdateCredentialRequest.TotpPeriod = kernel.Opt(in.TotpPeriod.Value) + } if len(in.Values) > 0 { params.UpdateCredentialRequest.Values = in.Values } @@ -374,6 +440,9 @@ Examples: # Create a credential with TOTP for 2FA kernel credentials create --name "my-2fa-site" --domain "example.com" --value "username=myuser" --value "password=mypass" --totp-secret "JBSWY3DPEHPK3PXP" + # Create a credential with custom TOTP parameters + kernel credentials create --name "my-8digit-site" --domain "example.com" --value "username=myuser" --totp-secret "JBSWY3DPEHPK3PXP" --totp-algorithm SHA256 --totp-digits 8 --totp-period 60 + # Create a credential with SSO provider kernel credentials create --name "google-sso" --domain "example.com" --value "email=user@gmail.com" --value "password=mypass" --sso-provider google`, Args: cobra.NoArgs, @@ -398,7 +467,7 @@ var credentialsDeleteCmd = &cobra.Command{ var credentialsTotpCodeCmd = &cobra.Command{ Use: "totp-code ", Short: "Get the current TOTP code for a credential", - Long: `Returns the current 6-digit TOTP code for a credential with a configured totp_secret.`, + Long: `Returns the current TOTP code for a credential with a configured totp_secret.`, Args: cobra.ExactArgs(1), RunE: runCredentialsTotpCode, } @@ -427,7 +496,10 @@ func init() { credentialsCreateCmd.Flags().String("domain", "", "Target domain this credential is for (required)") credentialsCreateCmd.Flags().StringArray("value", []string{}, "Field name=value pair (repeatable, e.g., --value username=myuser --value password=mypass)") credentialsCreateCmd.Flags().String("sso-provider", "", "SSO provider (e.g., google, github, microsoft)") - credentialsCreateCmd.Flags().String("totp-secret", "", "Base32-encoded TOTP secret for 2FA") + credentialsCreateCmd.Flags().String("totp-secret", "", "Base32-encoded TOTP secret (16-128 chars) or otpauth://totp/... URI for 2FA") + credentialsCreateCmd.Flags().String("totp-algorithm", "", "HMAC algorithm for TOTP codes: SHA1, SHA256, or SHA512 (default SHA1; ignored when an otpauth:// URI supplies it)") + credentialsCreateCmd.Flags().Int64("totp-digits", 0, "Number of digits in generated TOTP codes (default 6; ignored when an otpauth:// URI supplies it)") + credentialsCreateCmd.Flags().Int64("totp-period", 0, "TOTP rotation period in seconds (default 30; ignored when an otpauth:// URI supplies it)") _ = credentialsCreateCmd.MarkFlagRequired("name") _ = credentialsCreateCmd.MarkFlagRequired("domain") @@ -435,7 +507,10 @@ func init() { addJSONOutputFlag(credentialsUpdateCmd) credentialsUpdateCmd.Flags().String("name", "", "New name for the credential") credentialsUpdateCmd.Flags().String("sso-provider", "", "SSO provider (set to empty string to remove)") - credentialsUpdateCmd.Flags().String("totp-secret", "", "Base32-encoded TOTP secret (set to empty string to remove)") + credentialsUpdateCmd.Flags().String("totp-secret", "", "Base32-encoded TOTP secret or otpauth://totp/... URI (set to empty string to remove)") + credentialsUpdateCmd.Flags().String("totp-algorithm", "", "HMAC algorithm for TOTP codes: SHA1, SHA256, or SHA512 (requires --totp-secret)") + credentialsUpdateCmd.Flags().Int64("totp-digits", 0, "Number of digits in generated TOTP codes (requires --totp-secret)") + credentialsUpdateCmd.Flags().Int64("totp-period", 0, "TOTP rotation period in seconds (requires --totp-secret)") credentialsUpdateCmd.Flags().StringArray("value", []string{}, "Field name=value pair to update (repeatable)") credentialsUpdateCmd.Flags().StringArray("remove-value-key", []string{}, "Field name to remove from the credential's stored values (repeatable). Removals are applied before --value is merged, so a key given to both keeps its new value") @@ -485,6 +560,9 @@ func runCredentialsCreate(cmd *cobra.Command, args []string) error { valuePairs, _ := cmd.Flags().GetStringArray("value") ssoProvider, _ := cmd.Flags().GetString("sso-provider") totpSecret, _ := cmd.Flags().GetString("totp-secret") + totpAlgorithm, _ := cmd.Flags().GetString("totp-algorithm") + totpDigits, _ := cmd.Flags().GetInt64("totp-digits") + totpPeriod, _ := cmd.Flags().GetInt64("totp-period") // Parse value pairs into map values := make(map[string]string) @@ -499,12 +577,15 @@ func runCredentialsCreate(cmd *cobra.Command, args []string) error { svc := client.Credentials c := CredentialsCmd{credentials: &svc} return c.Create(cmd.Context(), CredentialsCreateInput{ - Name: name, - Domain: domain, - Values: values, - SSOProvider: ssoProvider, - TotpSecret: totpSecret, - Output: output, + Name: name, + Domain: domain, + Values: values, + SSOProvider: ssoProvider, + TotpSecret: totpSecret, + TotpAlgorithm: totpAlgorithm, + TotpDigits: Int64Flag{Set: cmd.Flags().Changed("totp-digits"), Value: totpDigits}, + TotpPeriod: Int64Flag{Set: cmd.Flags().Changed("totp-period"), Value: totpPeriod}, + Output: output, }) } @@ -514,6 +595,9 @@ func runCredentialsUpdate(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") ssoProvider, _ := cmd.Flags().GetString("sso-provider") totpSecret, _ := cmd.Flags().GetString("totp-secret") + totpAlgorithm, _ := cmd.Flags().GetString("totp-algorithm") + totpDigits, _ := cmd.Flags().GetInt64("totp-digits") + totpPeriod, _ := cmd.Flags().GetInt64("totp-period") valuePairs, _ := cmd.Flags().GetStringArray("value") removeValueKeys, _ := cmd.Flags().GetStringArray("remove-value-key") @@ -534,6 +618,9 @@ func runCredentialsUpdate(cmd *cobra.Command, args []string) error { Name: name, SSOProvider: ssoProvider, TotpSecret: totpSecret, + TotpAlgorithm: totpAlgorithm, + TotpDigits: Int64Flag{Set: cmd.Flags().Changed("totp-digits"), Value: totpDigits}, + TotpPeriod: Int64Flag{Set: cmd.Flags().Changed("totp-period"), Value: totpPeriod}, Values: values, RemoveValueKeys: removeValueKeys, Output: output, diff --git a/cmd/credentials_test.go b/cmd/credentials_test.go new file mode 100644 index 00000000..a38477b8 --- /dev/null +++ b/cmd/credentials_test.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeTotpAlgorithm(t *testing.T) { + for input, want := range map[string]string{"SHA1": "SHA1", "sha256": "SHA256", " Sha512 ": "SHA512"} { + got, err := normalizeTotpAlgorithm(input) + require.NoError(t, err) + assert.Equal(t, want, got) + } + + _, err := normalizeTotpAlgorithm("md5") + assert.Error(t, err) +} diff --git a/go.mod b/go.mod index cf1c13b4..e6b76926 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.115.1-0.20261001192137-820e4fed1c29 + github.com/kernel/kernel-go-sdk v0.115.1-0.20261001195254-10c4031082d7 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 629914e6..e2fd89df 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.115.1-0.20261001192137-820e4fed1c29 h1:ioqhtHfJqNKP9u3Tvd/uyfKJnlkEP/Go0w8VEbRwl6c= -github.com/kernel/kernel-go-sdk v0.115.1-0.20261001192137-820e4fed1c29/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.115.1-0.20261001195254-10c4031082d7 h1:BKPhT9xScnh6x8enG4kjDx28Zij03R2WXZZ++Zj9v+A= +github.com/kernel/kernel-go-sdk v0.115.1-0.20261001195254-10c4031082d7/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 ea4aa73314097b1ddefc7a9c69d4ac4c3309e2ae Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:22:14 +0000 Subject: [PATCH 03/20] CLI: Update Go SDK to c026e806a1bdffd330b0f533d5f132d7d9b0e5aa Bumps kernel-go-sdk to v0.116.0 (c026e806a1bd). The SDK changes since 10c4031082d7 are release metadata only. A full enumeration of SDK methods against CLI commands found no coverage gaps. Config-registry endpoints are x-cli-skip. Tested: go build ./..., go test ./... (SDK version bump only, no new commands/flags) Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e6b76926..717881ee 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.115.1-0.20261001195254-10c4031082d7 + github.com/kernel/kernel-go-sdk v0.116.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 e2fd89df..d38ad738 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.115.1-0.20261001195254-10c4031082d7 h1:BKPhT9xScnh6x8enG4kjDx28Zij03R2WXZZ++Zj9v+A= -github.com/kernel/kernel-go-sdk v0.115.1-0.20261001195254-10c4031082d7/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.116.0 h1:NwZwl40sJ11lI8aHYSmMa0b6eXIXoZQVuH6UfPUaZX0= +github.com/kernel/kernel-go-sdk v0.116.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 dced2d638d3db9bb26c908772e6c5b2edf4c2256 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:30:58 +0000 Subject: [PATCH 04/20] CLI: Update SDK to 615cfaf0c5a8 and add vault webmcp_invoke operation - Bump github.com/kernel/kernel-go-sdk to 615cfaf0c5a80549cba3cd643c00b58c517f5475 - Add `webmcp_invoke` to `kernel vaults items invoke` (--params/--spec-file with browser_id, tool_ref, page_url, input, bindings, timeout_sec) for WebmcpInvokeVaultItemOperationRequestParam / VaultWebmcpBindingParam Tested: created vault + credential (populated via hosted collect form), vault-bound browser with a custom WebMCP tool on example.com; `vaults items invoke login webmcp_invoke --params ...` (table) and `--spec-file - -o json` both returned completed with vaulted values substituted; mismatched page_url returned HTTP 400 with guidance. Resources cleaned up. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- cmd/vaults.go | 10 ++- cmd/vaults_commands.go | 19 +++- cmd/vaults_operation_params.go | 15 +++- cmd/vaults_webmcp.go | 159 +++++++++++++++++++++++++++++++++ cmd/vaults_webmcp_test.go | 74 +++++++++++++++ go.mod | 2 +- go.sum | 4 +- 8 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 cmd/vaults_webmcp.go create mode 100644 cmd/vaults_webmcp_test.go diff --git a/README.md b/README.md index d7042489..e74857da 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Commands with JSON output support: - **Proxies**: `create`, `list`, `get`, `update`, `check` - **API Keys**: `create`, `list`, `get`, `update`, `rotate` - **Auth Connections**: `timeline` -- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect`, `fill`, and `prepare_checkout`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) +- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect`, `fill`, `webmcp_invoke`, and `prepare_checkout`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) - **Projects**: `update` - **Org**: `limits get/set` - **Apps**: `list`, `history` @@ -398,7 +398,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 AgentCard 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 or login fields; `collect --open` opens a credential item's hosted form | +| `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; `webmcp_invoke --params ''` invokes a WebMCP tool (from `browsers webmcp list`) with vaulted values bound to null input slots by JSON Pointer; `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 | diff --git a/cmd/vaults.go b/cmd/vaults.go index 45442d85..629d4671 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -236,12 +236,15 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) { return fmt.Errorf("prepare_checkout requires checkout parameters") } + if operation == "webmcp_invoke" && (params == nil || params.WebMCP == nil || open) { + return fmt.Errorf("webmcp_invoke requires --params and does not support --open") + } if isOnePasswordOperation(operation) && (params == nil || params.OnePassword == nil) { return fmt.Errorf("%s requires its documented parameters", operation) } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { - if operation == "fill" || operation == "1pw_fill" { + if operation == "fill" || operation == "1pw_fill" || operation == "webmcp_invoke" { return vaultFillLookupError(err, operation) } return util.CleanedUpSdkError{Err: err} @@ -260,7 +263,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par for _, op := range actions.Operations { if op.Type == operation { available = true - if output != "json" && operation != "fill" { + if output != "json" && operation != "fill" && operation != "webmcp_invoke" { pterm.Info.Println(op.Description) } break @@ -275,6 +278,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "fill" { return c.fill(ctx, vault, key, params.Fill, output) } + if operation == "webmcp_invoke" { + return c.webmcpInvoke(ctx, vault, key, params.WebMCP, output) + } if operation == "1pw_fill" { return c.onePasswordFill(ctx, vault, key, params.OnePassword, output) } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 5b2afb6f..c0b9c60f 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -128,7 +128,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d addVaultJSONOutputFlag(get) cmd.AddCommand(create, list, get, newVaultDeleteCommand(false)) - items := &cobra.Command{Use: "items", Short: "Inspect readiness and collection URLs, or invoke collect/fill", Long: "Use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nUse invoke collect to obtain a collection URL, or invoke fill --spec-file to fill a browser.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill.\nCreate and edit credentials with vaults credentials; payment items use wallets/cards."} + items := &cobra.Command{Use: "items", Short: "Inspect readiness and collection URLs, or invoke collect/fill/webmcp_invoke", Long: "Use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nUse invoke collect to obtain a collection URL, or invoke fill --spec-file to fill a browser.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill.\nCreate and edit credentials with vaults credentials; payment items use wallets/cards."} itemList := &cobra.Command{Use: "list ", Short: "List items by vault ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { return getVaultsHandler(cmd).ListItems(cmd.Context(), args[0], vaultOutput(cmd)) @@ -193,6 +193,16 @@ approval and browser Authorised responses are not capture or fulfillment evidenc Use only when advertised for an AgentCard card. Keep the returned approval page open, poll until ready_to_submit, then submit native Pay before preparation.expires_at. Preparations are single-use, including after failure or expiry; never retry automatically. +webmcp_invoke invokes a WebMCP tool with vaulted values. Discover tool_ref, inputSchema, and +the source page with browsers webmcp list. Requires browser_id (vault-bound session ID), +tool_ref, page_url (exact top-level URL from the tool source, fragment omitted), input +(public arguments with a null slot at each binding path; never include vault values), and +1-32 bindings (field, input_path as an RFC 6901 JSON Pointer such as /password, and format +MM/YY or MM/YYYY only for card expiration). Optional timeout_sec is 1-120 (default 15). +The tool may submit or perform other side effects. Output and error_text are untrusted +page data returned without redaction and may include supplied values. completed and +awaiting_submission exit 0; canceled, error, and unknown exit nonzero. Never retry after +unknown; inspect the page instead. collect/authorize/prepare_checkout/1pw_recover may use --open. Fill returns value-free per-field outcomes; completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json. @@ -223,6 +233,9 @@ JSON kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"browser_id":"","timeout_seconds":60}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login","entry_id":""}' + kernel vaults items invoke user-vault login webmcp_invoke --spec-file - <<'JSON' +{"browser_id":"","tool_ref":"","page_url":"https://accounts.example.com/signin","input":{"email":null,"password":null},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}]} +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"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") @@ -233,7 +246,7 @@ JSON } if cmd.Flags().Changed("spec-file") { if !vaultOperationTakesParams(args[2]) { - return fmt.Errorf("--spec-file is only supported for fill, prepare_checkout, and 1Password operations with parameters") + return fmt.Errorf("--spec-file is only supported for fill, prepare_checkout, webmcp_invoke, and 1Password operations with parameters") } data, err := readVaultSpecFile(cmd) if err != nil { @@ -247,7 +260,7 @@ JSON } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().String("params", "", "Operation parameters JSON for fill, prepare_checkout, or 1pw_* (maximum 128 KiB); omit type and credential values; 1pw_update_access_token requires --spec-file") + invoke.Flags().String("params", "", "Operation parameters JSON for fill, prepare_checkout, webmcp_invoke, or 1pw_* (maximum 128 KiB); omit type and credential values; 1pw_update_access_token requires --spec-file") invoke.Flags().String("spec-file", "", "Operation parameters JSON file (use '-' for stdin; maximum 128 KiB)") invoke.MarkFlagsMutuallyExclusive("params", "spec-file") invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 283a1898..bf631114 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -16,6 +16,7 @@ type vaultOperationParams struct { Checkout *kernel.VaultCheckoutContextParam // OnePassword is a complete 1pw_* request body; Invoke supplies the vault. OnePassword *kernel.VaultItemPerformOperationParams + WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam } func isOnePasswordOperation(operation string) bool { @@ -24,7 +25,7 @@ func isOnePasswordOperation(operation string) bool { // vaultOperationTakesParams reports whether an operation accepts --params or --spec-file. func vaultOperationTakesParams(operation string) bool { - return operation == "fill" || operation == "prepare_checkout" || (isOnePasswordOperation(operation) && operation != "1pw_recover") + return operation == "fill" || operation == "prepare_checkout" || operation == "webmcp_invoke" || (isOnePasswordOperation(operation) && operation != "1pw_recover") } type vaultFillParams struct { @@ -116,9 +117,19 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } return &vaultOperationParams{Checkout: checkout}, nil } + if operation == "webmcp_invoke" { + if !paramsSet { + return nil, fmt.Errorf("webmcp_invoke requires --params or --spec-file with browser_id, tool_ref, page_url, input, and bindings") + } + request, err := parseVaultWebMCPParams(raw) + if err != nil { + return nil, err + } + return &vaultOperationParams{WebMCP: request}, nil + } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill, prepare_checkout, and 1Password operations; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill, prepare_checkout, webmcp_invoke, and 1Password operations; authorize takes no parameters") } return nil, nil } diff --git a/cmd/vaults_webmcp.go b/cmd/vaults_webmcp.go new file mode 100644 index 00000000..16c0e777 --- /dev/null +++ b/cmd/vaults_webmcp.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/pterm/pterm" +) + +const vaultWebMCPUncertain = "the tool may have run and performed side effects; inspect the browser page and do not retry automatically" + +func parseVaultWebMCPParams(raw string) (*kernel.WebmcpInvokeVaultItemOperationRequestParam, error) { + object, err := vaultParamsObject(raw, "browser_id tool_ref page_url input bindings timeout_sec") + if err != nil { + return nil, err + } + request := kernel.WebmcpInvokeVaultItemOperationRequestParam{Type: kernel.WebmcpInvokeVaultItemOperationRequestTypeWebmcpInvoke} + if json.Unmarshal(object["browser_id"], &request.BrowserID) != nil || strings.TrimSpace(request.BrowserID) == "" { + return nil, fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") + } + if json.Unmarshal(object["tool_ref"], &request.ToolRef) != nil || strings.TrimSpace(request.ToolRef) == "" || len(request.ToolRef) > 128 { + return nil, fmt.Errorf("tool_ref must be a non-empty tool reference of at most 128 bytes from browsers webmcp list") + } + if json.Unmarshal(object["page_url"], &request.PageURL) != nil { + return nil, fmt.Errorf("page_url must be the exact absolute URL from the discovered tool source") + } + if u, err := url.ParseRequestURI(request.PageURL); err != nil || u.Scheme == "" { + return nil, fmt.Errorf("page_url must be the exact absolute URL from the discovered tool source") + } + var input map[string]json.RawMessage + if json.Unmarshal(object["input"], &input) != nil || input == nil { + return nil, fmt.Errorf("input must be a JSON object with a null slot at each binding path") + } + request.Input = make(map[string]any, len(input)) + for key, value := range input { + // Keep raw values so page-provided numbers and nulls are sent unchanged. + request.Input[key] = value + } + var bindings []json.RawMessage + if json.Unmarshal(object["bindings"], &bindings) != nil || len(bindings) < 1 || len(bindings) > 32 { + return nil, fmt.Errorf("bindings must be an array of 1-32 field bindings") + } + request.Bindings = make([]kernel.VaultWebmcpBindingParam, 0, len(bindings)) + for i, rawBinding := range bindings { + fields, err := vaultParamsObject(string(rawBinding), "field input_path format") + if err != nil { + return nil, fmt.Errorf("bindings[%d]: %w", i, err) + } + var binding kernel.VaultWebmcpBindingParam + if json.Unmarshal(fields["field"], &binding.Field) != nil || strings.TrimSpace(binding.Field) == "" { + return nil, fmt.Errorf("bindings[%d].field must be a non-empty field name", i) + } + if json.Unmarshal(fields["input_path"], &binding.InputPath) != nil || !strings.HasPrefix(binding.InputPath, "/") { + return nil, fmt.Errorf("bindings[%d].input_path must be a JSON Pointer to a null slot in input, such as /password", i) + } + if value, present := fields["format"]; present { + var format string + if json.Unmarshal(value, &format) != nil || (format != "MM/YY" && format != "MM/YYYY") { + return nil, fmt.Errorf("bindings[%d].format must be MM/YY or MM/YYYY", i) + } + binding.Format = kernel.Opt(format) + } + request.Bindings = append(request.Bindings, binding) + } + if value, ok := object["timeout_sec"]; ok { + var timeout *int64 + if json.Unmarshal(value, &timeout) != nil || timeout == nil || *timeout < 1 || *timeout > 120 { + return nil, fmt.Errorf("timeout_sec must be an integer between 1 and 120") + } + request.TimeoutSec = kernel.Opt(*timeout) + } + return &request, nil +} + +func (c VaultsCmd) webmcpInvoke(ctx context.Context, vault, key string, request *kernel.WebmcpInvokeVaultItemOperationRequestParam, output string) error { + // A lost response can hide completed side effects, so never retry an invocation. + response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfWebmcpInvoke: request}, option.WithMaxRetries(0)) + if err != nil { + var apiErr *kernel.Error + if !errors.As(err, &apiErr) { + return fmt.Errorf("webmcp_invoke result unavailable; %s", vaultWebMCPUncertain) + } + guidance := vaultWebMCPUncertain + switch apiErr.StatusCode { + case 400, 403, 404, 409: + guidance = "the tool was not invoked by this request; inspect the item, browser, tool_ref, page_url, and bindings before deciding on a new invocation; do not automatically retry" + } + var body struct { + Code string `json:"code"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil && body.Code != "" { + if message, ok := vaultFillErrorMessages[body.Code]; ok { + return fmt.Errorf("webmcp_invoke failed: %s (HTTP %d): %s; %s", body.Code, apiErr.StatusCode, message, guidance) + } + } + return fmt.Errorf("webmcp_invoke request failed (HTTP %d); %s", apiErr.StatusCode, guidance) + } + if response == nil { + return fmt.Errorf("empty webmcp_invoke result; %s", vaultWebMCPUncertain) + } + // Preserve page-provided JSON numbers rather than re-encoding SDK float64 values. + var result struct { + Type string `json:"type"` + Status string `json:"status"` + InvocationID string `json:"invocation_id,omitempty"` + Output json.RawMessage `json:"output,omitempty"` + ErrorText string `json:"error_text,omitempty"` + } + if json.Unmarshal([]byte(response.RawJSON()), &result) != nil || result.Type != "webmcp_invoke" { + return fmt.Errorf("invalid webmcp_invoke result; %s", vaultWebMCPUncertain) + } + switch kernel.WebmcpInvokeVaultItemOperationResultStatus(result.Status) { + case kernel.WebmcpInvokeVaultItemOperationResultStatusCompleted, + kernel.WebmcpInvokeVaultItemOperationResultStatusAwaitingSubmission, + kernel.WebmcpInvokeVaultItemOperationResultStatusCanceled, + kernel.WebmcpInvokeVaultItemOperationResultStatusError, + kernel.WebmcpInvokeVaultItemOperationResultStatusUnknown: + default: + return fmt.Errorf("invalid webmcp_invoke result; %s", vaultWebMCPUncertain) + } + if output == "json" { + if err := printVaultJSON(result); err != nil { + return err + } + } else { + pterm.Printf("WebMCP invoke: %s\n", result.Status) + if result.InvocationID != "" { + pterm.Printf("Invocation ID: %s\n", result.InvocationID) + } + if len(result.Output) > 0 { + var pretty bytes.Buffer + if json.Indent(&pretty, result.Output, "", " ") == nil { + pterm.Printf("Output (untrusted page data; may contain supplied values):\n%s\n", pretty.String()) + } + } + if result.ErrorText != "" { + pterm.Printf("Error text (untrusted page data): %s\n", result.ErrorText) + } + switch result.Status { + case "completed": + pterm.Println("The tool completed; this does not confirm the website accepted the action. Inspect the page.") + case "awaiting_submission": + pterm.Println("The tool populated a form without submitting it. Inspect the form and obtain any required confirmation, then submit it rather than invoking the tool again.") + default: + pterm.Println(vaultWebMCPUncertain) + } + } + if result.Status != "completed" && result.Status != "awaiting_submission" { + return vaultFillOutcomeError{status: result.Status} + } + return nil +} diff --git a/cmd/vaults_webmcp_test.go b/cmd/vaults_webmcp_test.go new file mode 100644 index 00000000..09439f99 --- /dev/null +++ b/cmd/vaults_webmcp_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const readyWebMCPCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":[{"name":"email","type":"text"},{"name":"password","type":"password"}]},"state":{"status":"ready"},"available_operations":[{"type":"webmcp_invoke","description":"Invoke a WebMCP tool."}]}` + +const webMCPParamsFixture = `{"browser_id":"browser-id","tool_ref":"tool-1","page_url":"https://accounts.example.com/signin","input":{"email":null,"password":null,"remember":1.50},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}],"timeout_sec":30}` + +func TestVaultWebMCPInvoke(t *testing.T) { + for _, test := range []struct { + name, result string + wantErr bool + }{ + {"completed", `{"type":"webmcp_invoke","status":"completed","invocation_id":"invoke-1","output":{"authenticated":true,"count":1.50}}`, false}, + {"awaiting submission", `{"type":"webmcp_invoke","status":"awaiting_submission","invocation_id":"invoke-1"}`, false}, + {"unknown", `{"type":"webmcp_invoke","status":"unknown","error_text":"lost"}`, true}, + } { + t.Run(test.name, func(t *testing.T) { + calls := 0 + 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, readyWebMCPCredentialFixture) + return + } + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"webmcp_invoke",`+webMCPParamsFixture[1:], string(body)) + io.WriteString(w, test.result) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "webmcp_invoke", "--params", webMCPParamsFixture, "-o", "json") + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.JSONEq(t, test.result, out) + assert.Equal(t, 2, calls) + }) + } +} + +func TestVaultWebMCPInvokeValidation(t *testing.T) { + for _, params := range []string{ + `{"tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}]}`, + `{"browser_id":"b","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"not a url","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":[],"bindings":[{"field":"password","input_path":"/p"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"p"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p","value":"secret"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}],"timeout_sec":121}`, + `{"type":"webmcp_invoke","browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}]}`, + } { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "webmcp_invoke", "--params", params) + assert.Error(t, err, params) + } + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "webmcp_invoke") + assert.ErrorContains(t, err, "webmcp_invoke requires --params") +} diff --git a/go.mod b/go.mod index 717881ee..8111729a 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.116.0 + github.com/kernel/kernel-go-sdk v0.116.1-0.20261002132145-615cfaf0c5a8 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 d38ad738..96d90c9b 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.116.0 h1:NwZwl40sJ11lI8aHYSmMa0b6eXIXoZQVuH6UfPUaZX0= -github.com/kernel/kernel-go-sdk v0.116.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.116.1-0.20261002132145-615cfaf0c5a8 h1:nwW+nLBGBo1//KJzr+QULIozpFXWhgD5mOo+FXo6QEM= +github.com/kernel/kernel-go-sdk v0.116.1-0.20261002132145-615cfaf0c5a8/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 f9ee5429df479919c32aa3839db7beb3a37d6fa3 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:42:09 +0000 Subject: [PATCH 05/20] CLI: Update Go SDK to a6798c8e6ccf5e701517face8497e522c6bba83e SDK bump only (v0.117.0 release; no API changes). Full enumeration of SDK methods vs CLI commands found no coverage gaps. Tested: go build, go test ./cmd/..., kernel browsers list Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8111729a..2696f88c 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.116.1-0.20261002132145-615cfaf0c5a8 + github.com/kernel/kernel-go-sdk v0.117.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 96d90c9b..080d8770 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.116.1-0.20261002132145-615cfaf0c5a8 h1:nwW+nLBGBo1//KJzr+QULIozpFXWhgD5mOo+FXo6QEM= -github.com/kernel/kernel-go-sdk v0.116.1-0.20261002132145-615cfaf0c5a8/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.117.0 h1:b6/am7RkJyhadi/98pMHAyuiwyEoDD6smZ4V92pUJ40= +github.com/kernel/kernel-go-sdk v0.117.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/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 226f6db84b981ca7baf5ce065826898bb4590378 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:00:56 +0000 Subject: [PATCH 06/20] CLI: Update SDK to 0aa2b3c772eb and add vaults list --query - Bump kernel-go-sdk to 0aa2b3c772eb7eff991947d84a8f0bbb3a879abe - Add --query to `kernel vaults list` (VaultListParams.Query); preserved in Next: hint - Forward explicit --since to InvocationFollowParams.Since in `kernel logs --invocation` Tested: vaults create/list --query (substring match, no match, -o json)/delete; logs --invocation [--since 1h]; go test ./... Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- cmd/logs.go | 7 ++++++- cmd/offset_pagination_test.go | 15 ++++++++++++++- cmd/vaults.go | 14 +++++++++++--- cmd/vaults_commands.go | 4 +++- go.mod | 2 +- go.sum | 4 ++-- 7 files changed, 38 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e74857da..45db1156 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,7 @@ cannot switch projects. | Command | Purpose / flags | | --- | --- | | `kernel vaults create --name ` | Create or retrieve the vault with that immutable name | -| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`; JSON includes `vaults` and optional `next_offset` | +| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`, `--query` (name substring or exact ID); JSON includes `vaults` and optional `next_offset` | | `kernel vaults get ` | Get by ID or name | | `kernel vaults delete ` | Invalidate the vault and all its items; `--yes` skips confirmation | | `kernel vaults wallets create --provider link\|agentcard --spec ''` | Connect/enroll a wallet using its provider's spec; `--open` opens a returned HTTPS action URL | diff --git a/cmd/logs.go b/cmd/logs.go index 4715eeb1..13fc92bf 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -65,7 +65,12 @@ func runLogs(cmd *cobra.Command, args []string) error { pterm.Info.Println("Showing recent logs (timeout after 3s with no events)") } - stream := client.Invocations.FollowStreaming(cmd.Context(), inv.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0)) + // Only forward --since when explicitly set so older invocations still show their full logs + invParams := kernel.InvocationFollowParams{} + if cmd.Flags().Changed("since") { + invParams.Since = kernel.Opt(since) + } + stream := client.Invocations.FollowStreaming(cmd.Context(), inv.ID, invParams, option.WithMaxRetries(0)) if stream.Err() != nil { return fmt.Errorf("failed to follow streaming: %w", stream.Err()) } diff --git a/cmd/offset_pagination_test.go b/cmd/offset_pagination_test.go index fed03c9c..2256aa45 100644 --- a/cmd/offset_pagination_test.go +++ b/cmd/offset_pagination_test.go @@ -72,7 +72,7 @@ func TestOffsetPaginationListCommands(t *testing.T) { case "projects": err = (ProjectsCmd{projects: &client.Projects}).List(context.Background(), ProjectsListInput{Limit: 20, Offset: 20, Output: "json"}) case "vaults": - err = (VaultsCmd{vaults: &client.Vaults}).List(context.Background(), 20, 20, "", "json") + err = (VaultsCmd{vaults: &client.Vaults}).List(context.Background(), 20, 20, "", "", "json") case "vault-provider-configs": err = (VaultProviderConfigsCmd{configs: &client.VaultProviderConfigs}).List(context.Background(), 20, 20, "json") } @@ -87,3 +87,16 @@ func TestOffsetPaginationListCommands(t *testing.T) { } } } + +func TestVaultsListQuery(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "my vault", r.URL.Query().Get("query")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", "true") + w.Header().Set("X-Next-Offset", "20") + _, _ = io.WriteString(w, "[]") + }) + setupStdoutCapture(t) + require.NoError(t, (VaultsCmd{vaults: &client.Vaults}).List(context.Background(), 20, 0, "my vault", "", "table")) + assert.Contains(t, outBuf.String(), `--query "my vault"`) +} diff --git a/cmd/vaults.go b/cmd/vaults.go index 629d4671..9525b701 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -51,12 +51,16 @@ func (c VaultsCmd) Get(ctx context.Context, vault, output string) error { return printVault(v, output) } -func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, output string) error { +func (c VaultsCmd) List(ctx context.Context, limit, offset int64, query, project, output string) error { if limit < 1 || limit > 100 || offset < 0 { return fmt.Errorf("--limit must be between 1 and 100; --offset must be non-negative") } var response *http.Response - page, err := c.vaults.List(ctx, kernel.VaultListParams{Limit: kernel.Opt(limit), Offset: kernel.Opt(offset)}, option.WithMaxRetries(0), option.WithResponseInto(&response)) + params := kernel.VaultListParams{Limit: kernel.Opt(limit), Offset: kernel.Opt(offset)} + if query != "" { + params.Query = kernel.Opt(query) + } + page, err := c.vaults.List(ctx, params, option.WithMaxRetries(0), option.WithResponseInto(&response)) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -88,7 +92,11 @@ func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, outpu if project != "" { projectFlag = fmt.Sprintf(" --project %q", project) } - pterm.Printf("Next: kernel%s vaults list --limit %d --offset %d\n", projectFlag, limit, pagination.NextOffset) + queryFlag := "" + if query != "" { + queryFlag = fmt.Sprintf(" --query %q", query) + } + pterm.Printf("Next: kernel%s vaults list --limit %d --offset %d%s\n", projectFlag, limit, pagination.NextOffset, queryFlag) } return nil } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index c0b9c60f..f65828e9 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -114,11 +114,13 @@ JSON output preserves returned public fields but omits unknown/opaque provider d RunE: func(cmd *cobra.Command, args []string) error { limit, _ := cmd.Flags().GetInt64("limit") offset, _ := cmd.Flags().GetInt64("offset") + query, _ := cmd.Flags().GetString("query") project, _ := cmd.Flags().GetString("project") - return getVaultsHandler(cmd).List(cmd.Context(), limit, offset, resolveProjectSelection(project), vaultOutput(cmd)) + return getVaultsHandler(cmd).List(cmd.Context(), limit, offset, query, resolveProjectSelection(project), vaultOutput(cmd)) }} list.Flags().Int64("limit", 20, "Maximum vaults to return (1-100)") list.Flags().Int64("offset", 0, "Number of vaults to skip") + list.Flags().String("query", "", "Case-insensitive substring match against vault name; IDs match by exact value") addVaultJSONOutputFlag(list) get := &cobra.Command{Use: "get ", Short: "Get a vault by ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, diff --git a/go.mod b/go.mod index 2696f88c..5f640e7c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.117.0 + github.com/kernel/kernel-go-sdk v0.117.1-0.20261002185134-0aa2b3c772eb github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 080d8770..00555a2f 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.117.0 h1:b6/am7RkJyhadi/98pMHAyuiwyEoDD6smZ4V92pUJ40= -github.com/kernel/kernel-go-sdk v0.117.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.117.1-0.20261002185134-0aa2b3c772eb h1:fFAfjOKETTzC+ZIUEBsyz049dWdOus0lweJ4WWbTphA= +github.com/kernel/kernel-go-sdk v0.117.1-0.20261002185134-0aa2b3c772eb/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 9700052c3e361ac1285642c240d5fd491b0dec4d Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:03:02 +0000 Subject: [PATCH 07/20] CLI: Update SDK to a1378239c479 and add kernel wallet/card provider Update kernel-go-sdk to a1378239c479aeeb6d360e0719426ff0c9821da6. - vaults wallets create / cards create: accept --provider kernel (KernelWalletVaultItemSpecParam, KernelCardVaultItemSpecParam) - Reject provider config/tokens-file for Kernel wallets and updates for Kernel cards (unsupported per API) - Document KernelWalletSpec and KernelCardSpec in spec help - Show merchant URL, card last4, and network token last4 (masks.token_last4) in item output; keep token_last4 in filtered JSON Tested: vaults wallets create --provider kernel (request forwarded; staging API returned "provider configuration is unavailable"), vaults cards create --provider kernel (API validated kernel spec), client-side rejection of cards update/provider-config for kernel, items get output, vault cleanup. Co-Authored-By: Claude Opus 5.5 --- cmd/vaults_commands.go | 23 +++++++++++++++-------- cmd/vaults_help.go | 19 +++++++++++++++++++ cmd/vaults_output.go | 11 ++++++++++- cmd/vaults_wallet_spec.go | 6 +++++- cmd/vaults_wallet_spec_test.go | 34 ++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 7 files changed, 86 insertions(+), 13 deletions(-) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index f65828e9..e7ba5809 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -270,7 +270,7 @@ JSON items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) wallets := &cobra.Command{Use: "wallets", Short: "Connect provider wallets and inspect funding methods"} - walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: "Create a wallet at an immutable key and follow the returned provider action.\n" + vaultSpecHelp + vaultWalletSpecHelp, Example: ` kernel vaults wallets create checkout wallet-1 \ --provider link --spec '{ @@ -281,7 +281,10 @@ JSON }' --open kernel vaults wallets create checkout wallet-1 \ - --provider agentcard --spec '{}'`, + --provider agentcard --spec '{}' + + kernel vaults wallets create checkout wallet-2 \ + --provider kernel --spec '{}' --open`, RunE: func(cmd *cobra.Command, args []string) error { spec, err := vaultWalletSpecFromFlags(cmd) if err != nil { @@ -335,13 +338,14 @@ func newVaultCardCommand(update bool) *cobra.Command { if update { use, short = "update", "Update a card spec when the API permits configuration" } - cmd := &cobra.Command{Use: use + " --provider --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: short + `. Neither create nor update authorizes a Link card. + cmd := &cobra.Command{Use: use + " --provider --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: short + `. Neither create nor update authorizes a Link or Kernel card. Requested cards accept a replacement spec. Pending issuance updates preserve omitted optional fields; explicit empty lists clear them. The API restricts fields after authorization starts; wallet/provider bindings cannot change. An uncertain update enters recovery_required and must not be retried. Checkout cards can be edited -between authorizations. Identical creates return existing state without resetting it. +between authorizations. Kernel cards cannot be updated; delete and create a new item. +Identical creates return existing state without resetting it. Never reconfigure the same item to retry a failed, timed-out, rejected, or indeterminate payment. A recovery item that permits abandonment must be deleted after explicit user confirmation before creating a replacement. ` + vaultSpecHelp + vaultCardSpecHelp, @@ -357,6 +361,9 @@ A recovery item that permits abandonment must be deleted after explicit user con if err != nil { return err } + if provider, _ := cmd.Flags().GetString("provider"); update && provider == "kernel" { + return fmt.Errorf("Kernel cards cannot be updated; delete the item and create a new one") + } return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], param.Override[kernel.CardVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) }} addVaultSpecFlags(cmd) @@ -365,7 +372,7 @@ A recovery item that permits abandonment must be deleted after explicit user con } func addVaultSpecFlags(cmd *cobra.Command) { - cmd.Flags().String("provider", "", "Provider: link or agentcard (required)") + cmd.Flags().String("provider", "", "Provider: link, agentcard, or kernel (required)") cmd.Flags().String("spec", "", "Raw JSON specification object (required); see types and examples above") _ = cmd.MarkFlagRequired("provider") _ = cmd.MarkFlagRequired("spec") @@ -373,8 +380,8 @@ func addVaultSpecFlags(cmd *cobra.Command) { func vaultSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { provider, _ := cmd.Flags().GetString("provider") - if provider != "link" && provider != "agentcard" { - return nil, fmt.Errorf("--provider must be link or agentcard") + if provider != "link" && provider != "agentcard" && provider != "kernel" { + return nil, fmt.Errorf("--provider must be link, agentcard, or kernel") } raw, _ := cmd.Flags().GetString("spec") var spec map[string]json.RawMessage diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index ea948f44..96e82466 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -45,6 +45,13 @@ type AgentCardWalletSpec = { provider_config?: ProviderConfigReference; // omit for Kernel-managed credentials user_id?: string; // usr_...; enrolled in this organization under the SAME config }; + +// Kernel-managed Visa/Mastercard agentic network token enrollment. Creation returns a +// card_enrollment action: the cardholder enters the card on a Kernel-hosted page. +// The card number never reaches Kernel or the CLI. No provider config or tokens. +type KernelWalletSpec = { + provider: "kernel"; +}; ` const vaultCardSpecHelp = ` @@ -73,6 +80,18 @@ type AgentCardCardSpec = { checkout_origin?: string; // top-level checkout origin for autopilot matching; update omission removes it }; +// One live purchase with a Kernel-enrolled card. Authorize obtains a network token and +// one-time code for fill on merchant_url's origin until expires_at. Visa purchases are +// not yet supported (authorize returns 400). Updates are not supported. +type KernelCardSpec = { + provider: "kernel"; + wallet: string; // Kernel wallet item key + amount: number; // integer minor units; 1..50000 + currency: string; // ISO 4217 three letters + merchant_name: string; // 1..255 characters + merchant_url: string; // HTTPS merchant checkout URL; fill is locked to its origin +}; + type LinkLineItem = { name: string; quantity?: number; // integer >= 1 diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index aa5ecff8..f35a5f29 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -63,7 +63,7 @@ var vaultItemFields = vaultOutputFields{ "request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields, }, "fields": {"*": vaultFieldsOf("has_value")}, - "masks": vaultFieldsOf("brand last4"), + "masks": vaultFieldsOf("brand last4 token_last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), "preparation": vaultFieldsOf("id status browser_id merchant_origin environment psp created_at expires_at approval_url"), "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), @@ -371,6 +371,15 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "agentcard" && item.Spec.CheckoutOrigin != "" { rows = append(rows, []string{"Checkout origin", item.Spec.CheckoutOrigin}) } + if item.Spec.Provider == "kernel" && item.Spec.MerchantURL != "" { + rows = append(rows, []string{"Merchant URL", item.Spec.MerchantURL}) + } + if masks := item.State.Masks; masks.Last4 != "" || masks.TokenLast4 != "" { + rows = append(rows, []string{"Card last4", util.OrDash(masks.Last4)}) + if masks.TokenLast4 != "" { + rows = append(rows, []string{"Network token last4", masks.TokenLast4}) + } + } } if item.State.JSON.Domains.Valid() { rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) diff --git a/cmd/vaults_wallet_spec.go b/cmd/vaults_wallet_spec.go index 8136b3a1..1cf5ded1 100644 --- a/cmd/vaults_wallet_spec.go +++ b/cmd/vaults_wallet_spec.go @@ -41,7 +41,11 @@ func vaultWalletSpecFromFlags(cmd *cobra.Command) (kernel.VaultItemUpsertParamsB return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, err } provider, _ := cmd.Flags().GetString("provider") - if provider == "agentcard" { + if provider == "kernel" { + if reference != nil || cmd.Flags().Changed("tokens-file") { + return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("Kernel wallets use Kernel-managed credentials; omit provider config and --tokens-file") + } + } else if provider == "agentcard" { if cmd.Flags().Changed("tokens-file") { return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("--tokens-file is only for imported Link wallet grants") } diff --git a/cmd/vaults_wallet_spec_test.go b/cmd/vaults_wallet_spec_test.go index 9711ddba..0d6f2be1 100644 --- a/cmd/vaults_wallet_spec_test.go +++ b/cmd/vaults_wallet_spec_test.go @@ -63,3 +63,37 @@ func TestVaultImportedAuthorizationPreservesRawFields(t *testing.T) { }) } } + +func TestVaultKernelWalletSendsProviderOnly(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Type string `json:"type"` + Spec map[string]json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "wallet", body.Type) + raw, err := json.Marshal(body.Spec) + require.NoError(t, err) + assert.JSONEq(t, `{"provider":"kernel"}`, string(raw)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, connectedWalletFixture) + }) + _, _, err := executeVaultInputCommand(t, client, "", "vaults", "wallets", "create", "checkout", "wallet-1", "--provider", "kernel", "--spec", "{}", "-o", "json") + require.NoError(t, err) +} + +func TestVaultKernelProviderRejectsUnsupportedInputs(t *testing.T) { + for name, args := range map[string][]string{ + "provider config": {"wallets", "create", "checkout", "wallet-1", "--provider", "kernel", "--spec", "{}", "--provider-config-name", "cfg"}, + "tokens file": {"wallets", "create", "checkout", "wallet-1", "--provider", "kernel", "--spec", "{}", "--tokens-file", "-"}, + "card update": {"cards", "update", "checkout", "card-1", "--provider", "kernel", "--spec", "{}"}, + } { + t.Run(name, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + }) + _, _, err := executeVaultInputCommand(t, client, "", append([]string{"vaults"}, args...)...) + require.Error(t, err) + }) + } +} diff --git a/go.mod b/go.mod index 5f640e7c..9983eb32 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.117.1-0.20261002185134-0aa2b3c772eb + github.com/kernel/kernel-go-sdk v0.117.1-0.20261002195533-a1378239c479 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 00555a2f..ecdcf67c 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.117.1-0.20261002185134-0aa2b3c772eb h1:fFAfjOKETTzC+ZIUEBsyz049dWdOus0lweJ4WWbTphA= -github.com/kernel/kernel-go-sdk v0.117.1-0.20261002185134-0aa2b3c772eb/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.117.1-0.20261002195533-a1378239c479 h1:Px2arL7n5jb76SGIIjxlelBKlAGonkc/kD3mreqDfbc= +github.com/kernel/kernel-go-sdk v0.117.1-0.20261002195533-a1378239c479/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 c4eb80770fdb868240a607892872d5c5c5691e80 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:18:18 +0000 Subject: [PATCH 08/20] CLI: Update Go SDK to 681b969b9d242674ecd59d40f488c9e7c50b607f Bump kernel-go-sdk to v0.118.0 (681b969). The SDK change is a release only (no API surface changes); full enumeration found no coverage gaps. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9983eb32..34000112 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.117.1-0.20261002195533-a1378239c479 + github.com/kernel/kernel-go-sdk v0.118.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 ecdcf67c..5dda8522 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.117.1-0.20261002195533-a1378239c479 h1:Px2arL7n5jb76SGIIjxlelBKlAGonkc/kD3mreqDfbc= -github.com/kernel/kernel-go-sdk v0.117.1-0.20261002195533-a1378239c479/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.118.0 h1:DuYBH0AL1u36PXPz3G7nc/toj2DV+kIUfsIAByKDNQc= +github.com/kernel/kernel-go-sdk v0.118.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 f6ae6a6c06787023764a649f6a86cf534f52d847 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:54:30 +0000 Subject: [PATCH 09/20] CLI: Update SDK to 73817c0b9c4e and add managed_auth credential provider Bump kernel-go-sdk to 73817c0b9c4e93502daf9ecaa0bcf27e11e827bb, which adds the managed_auth credential vault item spec (ManagedAuthCredentialVaultItemSpecInputParam). - vaults credentials create accepts spec provider "managed_auth" with connection_id and optional description - Display-safe vault output keeps spec.connection_id and state.fields[*].type, and no longer rewrites managed_auth state fields with has_value - Table output shows the managed auth connection; help/README document the flow Tested: vaults credentials create --spec-file (provider managed_auth) against the live API, then vaults items get (table + json), vaults items list, missing connection_id validation, unknown connection (404), cleanup via items delete and vaults delete. go test ./... passes. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++++++++ cmd/vaults_credentials.go | 26 ++++++++++++++++-- cmd/vaults_credentials_test.go | 2 +- cmd/vaults_managed_auth_test.go | 47 +++++++++++++++++++++++++++++++++ cmd/vaults_onepassword_test.go | 2 +- cmd/vaults_output.go | 17 +++++++++--- go.mod | 2 +- go.sum | 4 +-- 8 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 cmd/vaults_managed_auth_test.go diff --git a/README.md b/README.md index 45db1156..1ee3e304 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,17 @@ text/email values, definitions, version, and `has_value`. Sensitive values and T seeds are omitted. Credential spec input is capped at 128 KiB; write errors are redacted. +To reuse a managed auth connection's saved credential, create a credential with +provider `managed_auth` and the connection ID from `kernel auth connections list`. +The item stores no values and reads the connection's credential at fill time; it is +created `ready`, `state.fields` lists fill binding names, and `update` returns 409: + +```sh +kernel vaults credentials create user-vault amazon --spec-file - <<'JSON' +{"provider":"managed_auth","connection_id":"","description":"Amazon"} +JSON +``` + Vault names, item keys, and project ownership are immutable. Optionally select a project with `--project ` or `KERNEL_PROJECT`; otherwise, the API resolves the project from your credentials and its defaults (the default project for org-wide credentials, not all projects). diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 555521fd..58a66854 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -76,6 +76,14 @@ and requests instead of account. Supply either account or both secrets, never bo or stdin; they are write-only and never displayed. Never ask an end user for them. Replace the token with items invoke 1pw_update_access_token --spec-file.` +const vaultManagedAuthCredentialHelp = `Managed auth credentials (spec provider "managed_auth"): reference a managed auth +connection in the vault's project that already has a saved Kernel credential, with +connection_id (from auth connections list) and an optional description. The item +stores no values; fill reads the connection's saved credential at fill time, so +managed auth updates apply immediately. Items are created ready; state.fields lists +fill binding names without values. No collection form is offered and update returns +409. Deleting the item leaves the connection and its credential unchanged.` + const vaultCredentialHelp = `Create credentials for a website. ` + vaultCredentialPathsHelp + ` @@ -109,7 +117,9 @@ Collection URLs are bearer credentials: share only with the intended user. ` + vaultOnePasswordCredentialHelp + ` -` + vaultOnePasswordStoredTokenHelp +` + vaultOnePasswordStoredTokenHelp + ` + +` + vaultManagedAuthCredentialHelp func newVaultCredentialsCommand() *cobra.Command { group := &cobra.Command{Use: "credentials", Short: "Collect, update, and fill user credentials", Long: vaultCredentialHelp} @@ -148,6 +158,11 @@ JSON # 1Password brokered approval (account is the connected credential_account key) kernel vaults credentials create user-vault github --spec-file - <<'JSON' {"provider":"1password","account":"onepassword","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}} +JSON + + # Managed auth connection with a saved credential + kernel vaults credentials create user-vault amazon --spec-file - <<'JSON' +{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"} JSON` } cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)") @@ -282,8 +297,15 @@ func credentialSpecInput(data []byte) (kernel.CredentialVaultItemSpecInputUnionP return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("1Password credential spec requires requests with 1-5 login entries") } return kernel.CredentialVaultItemSpecInputUnionParam{Of1password: &spec}, nil + case "managed_auth": + var spec kernel.ManagedAuthCredentialVaultItemSpecInputParam + if json.Unmarshal(data, &spec) != nil || strings.TrimSpace(spec.ConnectionID) == "" { + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("managed auth credential spec requires connection_id") + } + spec.Provider = kernel.ManagedAuthCredentialVaultItemSpecInputProviderManagedAuth + return kernel.CredentialVaultItemSpecInputUnionParam{OfManagedAuth: &spec}, nil default: - return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec provider must be kernel or 1password") + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec provider must be kernel, 1password, or managed_auth") } } diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index 008ea306..fd95f0d8 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -218,5 +218,5 @@ func TestCredentialSpecInputProvider(t *testing.T) { assert.EqualValues(t, "kernel", spec.OfKernel.Provider) _, err = credentialSpecInput([]byte(`{"provider":"bitwarden","fields":[{"name":"password","type":"password"}]}`)) - assert.EqualError(t, err, "credential spec provider must be kernel or 1password") + assert.EqualError(t, err, "credential spec provider must be kernel, 1password, or managed_auth") } diff --git a/cmd/vaults_managed_auth_test.go b/cmd/vaults_managed_auth_test.go new file mode 100644 index 00000000..5de2da2a --- /dev/null +++ b/cmd/vaults_managed_auth_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const managedAuthCredentialFixture = `{"id":"credential-3","key":"amazon","type":"credential","version":1,"spec":{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"},"state":{"provider":"managed_auth","status":"ready","fields":{"username":{"type":"email"},"password":{"type":"password"}}},"available_operations":[{"type":"fill","description":"Fill the login form."}],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` + +func TestCredentialSpecInputManagedAuth(t *testing.T) { + spec, err := credentialSpecInput([]byte(`{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"}`)) + require.NoError(t, err) + require.NotNil(t, spec.OfManagedAuth) + assert.Equal(t, "ma_abc123xyz", spec.OfManagedAuth.ConnectionID) + assert.Equal(t, "Amazon", spec.OfManagedAuth.Description.Value) + + _, err = credentialSpecInput([]byte(`{"provider":"managed_auth"}`)) + assert.EqualError(t, err, "managed auth credential spec requires connection_id") +} + +func TestCredentialCreateManagedAuth(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/vaults/user/items/amazon", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","spec":{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"}}`, string(body)) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, managedAuthCredentialFixture) + }) + spec := `{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"}` + out, _, err := executeVaultInputCommand(t, client, spec, "vaults", "credentials", "create", "user", "amazon", "--spec-file", "-", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"connection_id": "ma_abc123xyz"`) + assert.Contains(t, out, `"type": "password"`) + assert.NotContains(t, out, `"has_value"`) + + out, text, err := executeVaultInputCommand(t, client, spec, "vaults", "credentials", "create", "user", "amazon", "--spec-file", "-") + require.NoError(t, err) + assert.Contains(t, out+text, "Managed auth connection (immutable)") + assert.Contains(t, out+text, "ma_abc123xyz") +} diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index ec7d6b72..e6f3aef7 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -79,7 +79,7 @@ func TestCredentialCreateOnePassword(t *testing.T) { {`{"provider":"1password","account":"onepassword"}`, "1-5 login entries"}, {`{"provider":"1password","access_token":"token-secret","integration_key":"key-secret","website":"https://github.com"}`, "1-5 login entries"}, {`{"provider":"1password","account":"onepassword","requests":{"version":2,"entries":[` + strings.Repeat(entry+",", 5) + entry + `]}}`, "1-5 login entries"}, - {`{"provider":"lastpass","fields":[{"name":"password","type":"password"}]}`, "kernel or 1password"}, + {`{"provider":"lastpass","fields":[{"name":"password","type":"password"}]}`, "kernel, 1password, or managed_auth"}, } { _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user", "github", "--spec-file", credentialSpecFile(t, tc.spec)) require.ErrorContains(t, err, tc.err, tc.spec) diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index f35a5f29..09f19569 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -45,7 +45,7 @@ var vaultItemFields = vaultOutputFields{ "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "checkout_origin": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, - "context": nil, "expires_at": nil, "description": nil, "account": nil, + "context": nil, "expires_at": nil, "description": nil, "account": nil, "connection_id": nil, "requests": onePasswordRequestFields, "fields": vaultFieldsOf("name label type required sensitive"), "provider_config": vaultFieldsOf("id name"), @@ -62,7 +62,7 @@ var vaultItemFields = vaultOutputFields{ "id": nil, "state": nil, "goal": nil, "createdAt": nil, "has_autofill_token": nil, "granted_count": nil, "request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields, }, - "fields": {"*": vaultFieldsOf("has_value")}, + "fields": {"*": vaultFieldsOf("has_value type")}, "masks": vaultFieldsOf("brand last4 token_last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), "preparation": vaultFieldsOf("id status browser_id merchant_origin environment psp created_at expires_at approval_url"), @@ -155,7 +155,8 @@ func preservePublicCredentialValues(source, result vaultJSON) error { Sensitive *bool `json:"sensitive"` } var spec struct { - Fields []definition `json:"fields"` + Provider string `json:"provider"` + Fields []definition `json:"fields"` } var values struct { Fields map[string]struct { @@ -166,6 +167,10 @@ func preservePublicCredentialValues(source, result vaultJSON) error { if json.Unmarshal(source["spec"], &spec) != nil || json.Unmarshal(source["state"], &values) != nil || values.Fields == nil { return nil } + // Managed auth state lists binding names and types only; values are never returned. + if spec.Provider == "managed_auth" { + return nil + } definitions := make(map[string]definition, len(spec.Fields)) for _, field := range spec.Fields { definitions[field.Name] = field @@ -339,6 +344,8 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { } } } + } else if item.Spec.Provider == "managed_auth" { + rows = append(rows, []string{"Managed auth connection (immutable)", item.Spec.ConnectionID}) } else { pterm.Info.Println("Use -o json for field definitions, presence, and non-sensitive values; sensitive values are omitted") } @@ -456,6 +463,10 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction pterm.Info.Println("Ready means the account owner approved access, not that sign-in succeeded. 1pw_fill submits the form; inspect the page afterward. Never retry a request or fill automatically; after an uncertain outcome, do not delete and recreate the item.") return } + if item.Type == "credential" && item.Spec.Provider == "managed_auth" { + pterm.Info.Println("Fill reads the managed auth connection's saved credential at fill time; use -o json for fill binding names. Ready means a saved credential exists, not that login succeeded. Fill only when advertised; fill does not submit the form.") + return + } if item.Type == "credential" { if actions.RequiredAction != "" { pterm.Info.Println("Share the collection URL with the user to complete the credential form. Observe readiness with items get --wait 60; for edits to an already-ready item, compare versions without --wait.") diff --git a/go.mod b/go.mod index 34000112..1ac9ffe2 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.118.0 + github.com/kernel/kernel-go-sdk v0.118.1-0.20261002224644-73817c0b9c4e 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 5dda8522..d82c1778 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.118.0 h1:DuYBH0AL1u36PXPz3G7nc/toj2DV+kIUfsIAByKDNQc= -github.com/kernel/kernel-go-sdk v0.118.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.118.1-0.20261002224644-73817c0b9c4e h1:nkIuMloztza1OnJP1wL5cuBzf/8pOKGpO4yXhDAz84M= +github.com/kernel/kernel-go-sdk v0.118.1-0.20261002224644-73817c0b9c4e/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 21ce1a27644532a9c631cd9b9dfcc2e87c2af136 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:11:54 +0000 Subject: [PATCH 10/20] CLI: Update Go SDK to 2f367635f58ffca59a764f8a8280784fb0f9f7e7 (v0.119.0) Full enumeration of SDK methods vs CLI commands found no coverage gaps; the SDK change contains only release metadata (version/changelog). Tested: go build ./..., go vet ./..., go test ./... Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1ac9ffe2..2e424b9d 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.118.1-0.20261002224644-73817c0b9c4e + github.com/kernel/kernel-go-sdk v0.119.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 d82c1778..75816424 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.118.1-0.20261002224644-73817c0b9c4e h1:nkIuMloztza1OnJP1wL5cuBzf/8pOKGpO4yXhDAz84M= -github.com/kernel/kernel-go-sdk v0.118.1-0.20261002224644-73817c0b9c4e/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.119.0 h1:BF0VowtcKTot27DdK3dT5GngardUuUYkbn0GH9QLBCc= +github.com/kernel/kernel-go-sdk v0.119.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 465b4344f3bb930a734cc072a6cefc76c7ba69e9 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:27:50 +0000 Subject: [PATCH 11/20] CLI: Update Go SDK to acf8805822d0adbfe0be88647246ad0c53448fb2 Bump kernel-go-sdk to acf8805 (Exa highlights / Perplexity context-size doc changes; reachable via search --request JSON). Fix compile errors left by the main merge: drop duplicated WebMCP params field, invoke dispatch, validation, and help/example text in favor of main's version. Full enumeration of SDK methods vs CLI commands found no new gaps. Tested: go build ./..., go vet ./..., go test ./..., vaults list --query -o json Co-Authored-By: Claude Opus 5.5 --- cmd/vaults.go | 8 +------- cmd/vaults_commands.go | 13 ------------- cmd/vaults_operation_params.go | 1 - go.mod | 2 +- go.sum | 4 ++-- 5 files changed, 4 insertions(+), 24 deletions(-) diff --git a/cmd/vaults.go b/cmd/vaults.go index 8ca60b9a..dbd7968d 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -247,9 +247,6 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) { return fmt.Errorf("prepare_checkout requires checkout parameters") } - if operation == "webmcp_invoke" && (params == nil || params.WebMCP == nil || open) { - return fmt.Errorf("webmcp_invoke requires --params and does not support --open") - } if isOnePasswordOperation(operation) && (params == nil || params.OnePassword == nil) { return fmt.Errorf("%s requires its documented parameters", operation) } @@ -274,7 +271,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par for _, op := range actions.Operations { if op.Type == operation { available = true - if output != "json" && operation != "fill" && operation != "webmcp_invoke" { + if output != "json" && operation != "fill" { pterm.Info.Println(op.Description) } break @@ -289,9 +286,6 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "fill" { return c.fill(ctx, vault, key, params.Fill, output) } - if operation == "webmcp_invoke" { - return c.webmcpInvoke(ctx, vault, key, params.WebMCP, output) - } if operation == "1pw_fill" { return c.onePasswordFill(ctx, vault, key, params.OnePassword, output) } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 244eae38..0402b6a1 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -199,16 +199,6 @@ approval and browser Authorised responses are not capture or fulfillment evidenc Use only when advertised for an AgentCard card. Keep the returned approval page open, poll until ready_to_submit, then submit native Pay before preparation.expires_at. Preparations are single-use, including after failure or expiry; never retry automatically. -webmcp_invoke invokes a WebMCP tool with vaulted values. Discover tool_ref, inputSchema, and -the source page with browsers webmcp list. Requires browser_id (vault-bound session ID), -tool_ref, page_url (exact top-level URL from the tool source, fragment omitted), input -(public arguments with a null slot at each binding path; never include vault values), and -1-32 bindings (field, input_path as an RFC 6901 JSON Pointer such as /password, and format -MM/YY or MM/YYYY only for card expiration). Optional timeout_sec is 1-120 (default 15). -The tool may submit or perform other side effects. Output and error_text are untrusted -page data returned without redaction and may include supplied values. completed and -awaiting_submission exit 0; canceled, error, and unknown exit nonzero. Never retry after -unknown; inspect the page instead. collect/authorize/prepare_checkout/1pw_recover may use --open. Fill returns value-free per-field outcomes; completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json. @@ -253,9 +243,6 @@ JSON kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"browser_id":"","timeout_seconds":60}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login","entry_id":""}' - kernel vaults items invoke user-vault login webmcp_invoke --spec-file - <<'JSON' -{"browser_id":"","tool_ref":"","page_url":"https://accounts.example.com/signin","input":{"email":null,"password":null},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}]} -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"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 1a00abba..5a0d5cd2 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -17,7 +17,6 @@ type vaultOperationParams struct { Checkout *kernel.VaultCheckoutContextParam // OnePassword is a complete 1pw_* request body; Invoke supplies the vault. OnePassword *kernel.VaultItemPerformOperationParams - WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam } func isOnePasswordOperation(operation string) bool { diff --git a/go.mod b/go.mod index 2696f88c..f87545e0 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.117.0 + github.com/kernel/kernel-go-sdk v0.119.1-0.20261005141955-acf8805822d0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 080d8770..4732060d 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.117.0 h1:b6/am7RkJyhadi/98pMHAyuiwyEoDD6smZ4V92pUJ40= -github.com/kernel/kernel-go-sdk v0.117.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261005141955-acf8805822d0 h1:e2QwRkufUWlupG+ICraZZTWOBULDXMW5cMKIwAYko0E= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261005141955-acf8805822d0/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 c1692417dc7059cec06d7fdf36213e61ba9f9207 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:23:23 +0000 Subject: [PATCH 12/20] CLI: Update Go SDK to f96f8682ead8a515498bb3363da9c68818dcde23 Bump kernel-go-sdk to f96f8682ead8a515498bb3363da9c68818dcde23. The SDK change only updates Kernel wallet documentation (wallets connect once the card is stored; network tokens are best-effort), so the KernelWalletSpec help text is updated to match. Full SDK/CLI enumeration found no coverage gaps. Tested: go build ./..., go test ./..., vaults wallets create --help Co-Authored-By: Claude Opus 5.5 --- cmd/vaults_help.go | 7 +++++-- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 96e82466..4dff8bef 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -46,8 +46,11 @@ type AgentCardWalletSpec = { user_id?: string; // usr_...; enrolled in this organization under the SAME config }; -// Kernel-managed Visa/Mastercard agentic network token enrollment. Creation returns a -// card_enrollment action: the cardholder enters the card on a Kernel-hosted page. +// One card stored with Kernel-managed credentials. Creation returns a card_enrollment +// action: the cardholder enters the card on a Kernel-hosted page, and the wallet +// connects once the card is stored. Kernel then enrolls it for an agentic network +// token when the issuer supports it; until then payment-methods reports +// capabilities.single_use_card.eligible=false and authorize returns 400. // The card number never reaches Kernel or the CLI. No provider config or tokens. type KernelWalletSpec = { provider: "kernel"; diff --git a/go.mod b/go.mod index f87545e0..fe7c66ad 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.119.1-0.20261005141955-acf8805822d0 + github.com/kernel/kernel-go-sdk v0.119.1-0.20261006171711-f96f8682ead8 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 4732060d..3b3bd48d 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261005141955-acf8805822d0 h1:e2QwRkufUWlupG+ICraZZTWOBULDXMW5cMKIwAYko0E= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261005141955-acf8805822d0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006171711-f96f8682ead8 h1:l+60DYfadSH5ETHSrGMej6BqMbg9yT2qpQjM1kiz2l0= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006171711-f96f8682ead8/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 9b9d484c6d73a564ad9ce7a517f046473b9f3e4b Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:43:58 +0000 Subject: [PATCH 13/20] CLI: Update Go SDK to be15dab89b9b8e2a23c27f8977255a69388ba8a4 and add --allowed-host - Bump github.com/kernel/kernel-go-sdk to be15dab89b9b8e2a23c27f8977255a69388ba8a4 - browsers create: add --allowed-host for BrowserNetworkConfigParam.AllowedHosts (egress allowlist, max 100 entries, create-only, conflicts with pool flags) - browsers create/get: show Allowed Hosts row Tested: - go test ./cmd/ passes (new TestBrowsersCreate_WithAllowedHosts) - browsers create --allowed-host example.com,*.example.com --start-url https://example.com: request reaches API, which returns feature_not_enabled (org flag gated) - browsers create / get: Allowed Hosts row renders '-' when unset; deleted - browsers create --pool-name x --allowed-host ...: flagged as pool conflict Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + cmd/browsers.go | 36 ++++++++++++++++++++++++++++++++++-- cmd/browsers_test.go | 32 ++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bef93f6d..ed557db2 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `--kiosk` - Launch browser in kiosk mode - `--region us-east|eu-west|ap-southeast` - Geographic region for the session. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to `us-east`. - `--private-host ` - Destination the browser reaches directly through the session's own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeatable or comma-separated, max 32). Accepts hostname patterns (`*.example.ts.net`), IPs (`10.1.30.63`, `[fd00::1]`), and private CIDRs (`100.64.0.0/10`). Replaces the default private ranges (RFC1918, `100.64.0.0/10`, `fc00::/7`); omit to keep them. Fixed once the session is created. Unrelated to a proxy's `--bypass-host`, which only chooses between upstream proxy and Kernel-managed direct egress. + - `--allowed-host ` - Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeatable or comma-separated, max 100). Other destinations are refused with a 403 whose `X-Kernel-Proxy-Error` header is `network_policy_denied`. Accepts exact hostnames (`example.com`), a leading wildcard that matches subdomains but not the domain itself (`*.example.com`), public IPs (`8.8.8.8`, `[2001:4860:4860::8888]`), and public CIDRs (`8.8.4.0/24`); no ports, paths, or schemes. `--start-url` must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Create-only. - `--proxy-route '[,...]='` - Route matching browser requests through a selected proxy (repeatable, max 10 routes with 1–50 hosts each). Example: `--proxy-route 'api.ipify.org,*.ipify.org=name:my-dc-proxy'`. The proxy is an ID by default; use `id:` or `name:` explicitly. Exact hostnames beat wildcards; longer wildcard suffixes beat shorter ones. `*.example.com` matches subdomains, not `example.com`. Matching ignores case and ports. Unmatched hosts use `--proxy-*` or default egress, while `--start-url` uses the top-level proxy during setup. Routes are create-only and are not available on pool sessions. - `--start-url ` - Initial page to open on launch - `--proxy-id ` / `--proxy-name ` - Use that proxy for the session regardless of stealth (mutually exclusive with each other and with `--proxy-mode`) diff --git a/cmd/browsers.go b/cmd/browsers.go index 1e79b836..51e06750 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -236,6 +236,19 @@ func buildNetworkParam(privateHosts []string) (kernel.BrowserNetworkConfigParam, return network, nil } +// maxAllowedHosts mirrors the API's cap on network.allowed_hosts entries. +const maxAllowedHosts = 100 + +// normalizeAllowedHosts trims --allowed-host values, drops empty ones, and +// enforces the API's entry cap. Entry syntax is validated by the API. +func normalizeAllowedHosts(hosts []string) ([]string, error) { + out := normalizePrivateHosts(hosts) + if len(out) > maxAllowedHosts { + return nil, fmt.Errorf("too many --allowed-host entries: %d (maximum %d)", len(out), maxAllowedHosts) + } + return out, nil +} + const ( maxProxyRoutes = 10 maxProxyRouteHosts = 50 @@ -300,6 +313,15 @@ func formatProxyRoutes(network kernel.BrowserNetworkConfig) string { return strings.Join(routes, "; ") } +// formatAllowedHosts renders a session's egress allowlist for table output. A +// missing allowed_hosts list means egress is unfiltered. +func formatAllowedHosts(network kernel.BrowserNetworkConfig) string { + if len(network.AllowedHosts) == 0 { + return "-" + } + return strings.Join(network.AllowedHosts, ", ") +} + // formatPrivateHosts renders a network configuration for table output. A missing // private_hosts list means the API's default private ranges apply; an explicit // empty list means nothing routes around Kernel-managed egress. @@ -460,6 +482,7 @@ type BrowsersCreateInput struct { ProxyMode string Region string PrivateHosts []string + AllowedHosts []string ProxyRoutes []string StartURL string Extensions []string @@ -733,7 +756,12 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { return err } network.ProxyRoutes = routes - if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 { + allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts) + if err != nil { + return err + } + network.AllowedHosts = allowedHosts + if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 || len(network.AllowedHosts) > 0 { params.Network = network } @@ -811,7 +839,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } tableData := buildBrowserTableData(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags) - tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}, []string{"Proxy Routes", formatProxyRoutes(browser.Network)}) + tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}, []string{"Allowed Hosts", formatAllowedHosts(browser.Network)}, []string{"Proxy Routes", formatProxyRoutes(browser.Network)}) PrintTableNoPad(tableData, true) if len(browser.Vaults) > 0 { rows := pterm.TableData{{"Attached vault ID", "Name"}} @@ -954,6 +982,7 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { tableData = append(tableData, []string{"Proxy", proxy}) } tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}) + tableData = append(tableData, []string{"Allowed Hosts", formatAllowedHosts(browser.Network)}) tableData = append(tableData, []string{"Proxy Routes", formatProxyRoutes(browser.Network)}) if vaults := formatVaultReferences(browser.Vaults); vaults != "" { tableData = append(tableData, []string{"Vaults", vaults}) @@ -3253,6 +3282,7 @@ unrestricted code execution inside the browser VM and is not sandboxed.`, browsersCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the browser default (Kernel's stealth proxy when --stealth is set, direct egress otherwise)") browsersCreateCmd.Flags().String("region", "", "Geographic region for the session: 'us-east', 'eu-west', or 'ap-southeast'. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to us-east") browsersCreateCmd.Flags().StringSlice("private-host", nil, "Destinations the browser reaches directly through its own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them. Fixed once the session is created") + browsersCreateCmd.Flags().StringSlice("allowed-host", nil, "Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeat or comma-separated, max 100); anything else is refused with a 403 (network_policy_denied). Accepts exact hostnames ('example.com'), a leading wildcard matching subdomains only ('*.example.com'), public IPs ('8.8.8.8', '[2001:4860:4860::8888]'), and public CIDRs ('8.8.4.0/24'). No ports, paths, or schemes. --start-url must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Create-only") browsersCreateCmd.Flags().StringArray("proxy-route", nil, "Route HOST[,HOST...]=PROXY through a proxy (repeatable, max 10 routes and 50 hosts per route). PROXY is an ID by default; use id:ID or name:NAME explicitly. Exact hosts beat wildcards (longer suffixes win); *.example.com excludes example.com. Unmatched hosts use --proxy-* or default egress; start_url uses the top-level proxy. Create-only") browsersCreateCmd.Flags().String("start-url", "", "Initial page to open on launch") browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") @@ -3392,6 +3422,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { proxyMode, _ := cmd.Flags().GetString("proxy-mode") region, _ := cmd.Flags().GetString("region") privateHosts, _ := cmd.Flags().GetStringSlice("private-host") + allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") proxyRoutes, _ := cmd.Flags().GetStringArray("proxy-route") startURL, _ := cmd.Flags().GetString("start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") @@ -3543,6 +3574,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { ProxyMode: proxyMode, Region: region, PrivateHosts: privateHosts, + AllowedHosts: allowedHosts, ProxyRoutes: proxyRoutes, StartURL: startURL, Extensions: extensions, diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 801fe04c..6cb0036c 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -610,6 +610,38 @@ func TestBrowsersCreate_WithPrivateHosts(t *testing.T) { })) } +func TestBrowsersCreate_WithAllowedHosts(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-allowlist"}, nil + }, + } + + err := (BrowsersCmd{browsers: fake}).Create(context.Background(), BrowsersCreateInput{ + AllowedHosts: []string{" example.com ", "*.example.com", ""}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"example.com", "*.example.com"}, captured.Network.AllowedHosts) + + raw, err := captured.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), `"allowed_hosts":["example.com","*.example.com"]`) + assert.NotContains(t, string(raw), "private_hosts") + + // The API's 100-entry cap is enforced client-side. + tooMany := make([]string, maxAllowedHosts+1) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("host-%d.example.com", i) + } + assert.Error(t, (BrowsersCmd{browsers: fake}).Create(context.Background(), BrowsersCreateInput{ + AllowedHosts: tooMany, + })) +} + func TestParseProxyRoutes(t *testing.T) { routes, err := parseProxyRoutes([]string{" api.ipify.org , *.ipify.org =name:my-dc-proxy", "other.example=id:proxy-123", "fallback.example=proxy-456"}) require.NoError(t, err) diff --git a/go.mod b/go.mod index fe7c66ad..f6389068 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.119.1-0.20261006171711-f96f8682ead8 + github.com/kernel/kernel-go-sdk v0.119.1-0.20261006183707-be15dab89b9b 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 3b3bd48d..2b2d8698 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006171711-f96f8682ead8 h1:l+60DYfadSH5ETHSrGMej6BqMbg9yT2qpQjM1kiz2l0= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006171711-f96f8682ead8/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006183707-be15dab89b9b h1:gKAq0sFluyC2GKVHZVrTmT5v5A626dvSxNTT4q3fXnY= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006183707-be15dab89b9b/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 9c858fea84a85bb3563ceaaa74b4370056b1d98f Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:29:52 +0000 Subject: [PATCH 14/20] CLI: Update Go SDK to 418cdfed18c8cf129147945463ebeccc22c2fb27 and document merchant_country - Bump kernel-go-sdk to 418cdfed18c8 (Visa passkey approval for Kernel cards) - Document optional merchant_country (ISO 3166-1 alpha-2, required for Visa) on the Kernel card spec, and replace the old "Visa purchases are not yet supported" note with the spend_approval flow - Show merchant_country in display-safe JSON output and as a "Merchant country" row for Kernel cards Tested: go test ./cmd/ passes. On a throwaway vault, vaults cards create --provider kernel with merchant_country "US" passed spec validation (then got 409 because the wallet had no enrolled card); "USAX" was rejected with 400 invalid Kernel card spec. The vault was deleted afterwards. Co-Authored-By: Claude Opus 5.5 --- cmd/vaults_help.go | 7 +++++-- cmd/vaults_output.go | 5 ++++- cmd/vaults_output_test.go | 20 ++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 4dff8bef..61231032 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -84,8 +84,10 @@ type AgentCardCardSpec = { }; // One live purchase with a Kernel-enrolled card. Authorize obtains a network token and -// one-time code for fill on merchant_url's origin until expires_at. Visa purchases are -// not yet supported (authorize returns 400). Updates are not supported. +// one-time code for fill on merchant_url's origin until expires_at. Mastercard purchases +// need no hosted approval. A Visa purchase returns a spend_approval action: the cardholder +// approves it with a Visa passkey (the link expires after 30 minutes) before the code is +// issued. Updates are not supported. type KernelCardSpec = { provider: "kernel"; wallet: string; // Kernel wallet item key @@ -93,6 +95,7 @@ type KernelCardSpec = { currency: string; // ISO 4217 three letters merchant_name: string; // 1..255 characters merchant_url: string; // HTTPS merchant checkout URL; fill is locked to its origin + merchant_country?: string; // ISO 3166-1 alpha-2; required for Visa cards }; type LinkLineItem = { diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 09f19569..2dfb6d53 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -44,7 +44,7 @@ var vaultItemFields = vaultOutputFields{ "expanded": {"payment_methods": vaultMethodFields}, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "checkout_origin": nil, - "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, + "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "merchant_country": nil, "context": nil, "expires_at": nil, "description": nil, "account": nil, "connection_id": nil, "requests": onePasswordRequestFields, "fields": vaultFieldsOf("name label type required sensitive"), @@ -381,6 +381,9 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "kernel" && item.Spec.MerchantURL != "" { rows = append(rows, []string{"Merchant URL", item.Spec.MerchantURL}) } + if item.Spec.Provider == "kernel" && item.Spec.MerchantCountry != "" { + rows = append(rows, []string{"Merchant country", item.Spec.MerchantCountry}) + } if masks := item.State.Masks; masks.Last4 != "" || masks.TokenLast4 != "" { rows = append(rows, []string{"Card last4", util.OrDash(masks.Last4)}) if masks.TokenLast4 != "" { diff --git a/cmd/vaults_output_test.go b/cmd/vaults_output_test.go index 034f05fe..b9fd8a05 100644 --- a/cmd/vaults_output_test.go +++ b/cmd/vaults_output_test.go @@ -104,6 +104,26 @@ func TestVaultOutputLinkHasNoAliases(t *testing.T) { } } +func TestVaultOutputKernelCardMerchantCountry(t *testing.T) { + var item kernel.VaultItemUnion + require.NoError(t, json.Unmarshal([]byte(`{ + "id":"card-id","key":"order-1","type":"card", + "spec":{"provider":"kernel","wallet":"wallet-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}, + "state":{"provider":"kernel","status":"pending_authorization"}, + "available_operations":[],"available_expansions":[] + }`), &item)) + buf := capturePtermOutput(t) + require.NoError(t, printVaultItem(&item, "")) + assert.Contains(t, buf.String(), "Merchant country") + assert.Contains(t, buf.String(), "US") + out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) }) + var decoded struct { + Spec vaultJSON `json:"spec"` + } + require.NoError(t, json.Unmarshal([]byte(out), &decoded)) + assert.JSONEq(t, `"US"`, string(decoded.Spec["merchant_country"])) +} + func TestVaultOutputAgentCardAuthorizationIsNotPaymentSuccess(t *testing.T) { var item kernel.VaultItemUnion require.NoError(t, json.Unmarshal([]byte(`{ diff --git a/go.mod b/go.mod index f6389068..b804426f 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.119.1-0.20261006183707-be15dab89b9b + github.com/kernel/kernel-go-sdk v0.119.1-0.20261006192232-418cdfed18c8 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 2b2d8698..f393e8db 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006183707-be15dab89b9b h1:gKAq0sFluyC2GKVHZVrTmT5v5A626dvSxNTT4q3fXnY= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006183707-be15dab89b9b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006192232-418cdfed18c8 h1:g1zA+KLA3DXNHY3rZwr0zZPGUeC6X7PWru0qnWc820I= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006192232-418cdfed18c8/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 60167857f177df5d659c775b6bcd1008d054d39a Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:19:08 +0000 Subject: [PATCH 15/20] CLI: Update Go SDK to 218107bfe6a52574986b3747eedf28753fe52abe and accept us-west region - Bump kernel-go-sdk to 218107bfe6a52574986b3747eedf28753fe52abe - Add "us-west" to the client-side --region validation used by browsers create/list, browser-pools create/list, and auth connections create/update/login; update flag help and README Tested: browsers list --region us-west, browser-pools list --region us-west (both succeed); browsers create --region us-west reaches the API (rejected server-side with region_not_enabled for the test org, as expected); --region emea still rejected client-side; go test ./... passes. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++++++------ cmd/auth_connections.go | 6 +++--- cmd/browser_pools.go | 4 ++-- cmd/browsers.go | 6 +++--- cmd/browsers_test.go | 4 ++++ go.mod | 2 +- go.sum | 4 ++-- 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ed557db2..89e3df0f 100644 --- a/README.md +++ b/README.md @@ -259,14 +259,14 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `kernel browsers list` - List running browsers - `--query ` - Search by name, session ID, profile ID, proxy ID, or pool name - - `--region us-east|eu-west|ap-southeast` - Filter by geographic region; omit to list sessions in all regions + - `--region us-east|us-west|eu-west|ap-southeast` - Filter by geographic region; omit to list sessions in all regions - `--tag ` - Filter by tag, repeatable; a session must match every pair - `--output json`, `-o json` - Output raw JSON array - `kernel browsers create` - Create a new browser session - `-s, --stealth` - Launch browser in stealth mode to avoid detection - `-H, --headless` - Launch browser without GUI access - `--kiosk` - Launch browser in kiosk mode - - `--region us-east|eu-west|ap-southeast` - Geographic region for the session. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to `us-east`. + - `--region us-east|us-west|eu-west|ap-southeast` - Geographic region for the session. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to `us-east`. - `--private-host ` - Destination the browser reaches directly through the session's own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeatable or comma-separated, max 32). Accepts hostname patterns (`*.example.ts.net`), IPs (`10.1.30.63`, `[fd00::1]`), and private CIDRs (`100.64.0.0/10`). Replaces the default private ranges (RFC1918, `100.64.0.0/10`, `fc00::/7`); omit to keep them. Fixed once the session is created. Unrelated to a proxy's `--bypass-host`, which only chooses between upstream proxy and Kernel-managed direct egress. - `--allowed-host ` - Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeatable or comma-separated, max 100). Other destinations are refused with a 403 whose `X-Kernel-Proxy-Error` header is `network_policy_denied`. Accepts exact hostnames (`example.com`), a leading wildcard that matches subdomains but not the domain itself (`*.example.com`), public IPs (`8.8.8.8`, `[2001:4860:4860::8888]`), and public CIDRs (`8.8.4.0/24`); no ports, paths, or schemes. `--start-url` must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Create-only. - `--proxy-route '[,...]='` - Route matching browser requests through a selected proxy (repeatable, max 10 routes with 1–50 hosts each). Example: `--proxy-route 'api.ipify.org,*.ipify.org=name:my-dc-proxy'`. The proxy is an ID by default; use `id:` or `name:` explicitly. Exact hostnames beat wildcards; longer wildcard suffixes beat shorter ones. `*.example.com` matches subdomains, not `example.com`. Matching ignores case and ports. Unmatched hosts use `--proxy-*` or default egress, while `--start-url` uses the top-level proxy during setup. Routes are create-only and are not available on pool sessions. @@ -787,7 +787,7 @@ exists. ### Browser Pools - `kernel browser-pools list` - List browser pools - - `--region us-east|eu-west|ap-southeast` - Filter by geographic region; omit to list pools in all regions + - `--region us-east|us-west|eu-west|ap-southeast` - Filter by geographic region; omit to list pools in all regions - `--output json`, `-o json` - Output raw JSON array - `kernel browser-pools create` - Create a browser pool - `--name ` - Optional unique name for the pool @@ -1181,21 +1181,21 @@ Managed auth connections (`kernel auth connections`). The commands below are new - `--per-page ` - Items per page (default: 20) - `--output json`, `-o json` - Output raw JSON array - `kernel auth connections create` - New flags: - - `--region us-east|eu-west|ap-southeast` - Region for this connection's login, reauth, and health-check browser sessions. Defaults to `us-east`. + - `--region us-east|us-west|eu-west|ap-southeast` - Region for this connection's login, reauth, and health-check browser sessions. Defaults to `us-east`. - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy configuration for this connection's login, reauth, and health-check browser sessions (mutually exclusive). Omit to derive the default from stealth. - `--stealth` - Whether those browser sessions run in stealth mode (default: true); use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Default telemetry for this connection's browser sessions. Same semantics as `kernel browsers create` - `--telemetry-export-otlp ` - Export this connection's captured telemetry over OTLP to one of the org's configured destinations. Implies `--telemetry=all` when `--telemetry` is not set. Use `=off` to disable export. - `--telemetry-storage on|off` - Whether this connection's sessions persist captured telemetry to Kernel storage (default on). `off` requires `--telemetry-export-otlp ` in the same command. - `kernel auth connections update ` - New flags: - - `--region us-east|eu-west|ap-southeast` - Update the region for browser sessions created after this command. Active sessions don't move. + - `--region us-east|us-west|eu-west|ap-southeast` - Update the region for browser sessions created after this command. Active sessions don't move. - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy configuration for future browser sessions (mutually exclusive). Use `--proxy-mode=default` to drop a selected proxy rather than passing an empty value. - `--stealth` - Set whether future browser sessions run in stealth mode; use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Update telemetry for future browser sessions - `--telemetry-export-otlp ` - Update where future sessions export captured telemetry. Naming a destination requires passing `--telemetry` in the same command, since the API validates capture and export together and enabling capture here would replace the connection's current category selection. Use `=off` to disable export. - `--telemetry-storage on|off` - Update whether future sessions persist captured telemetry to Kernel storage. Requires `--telemetry` in the same command; `off` also requires an export destination. - `kernel auth connections login ` - New flags: - - `--region us-east|eu-west|ap-southeast` - Region override for this login only. Omit it to inherit the connection region. + - `--region us-east|us-west|eu-west|ap-southeast` - Region override for this login only. Omit it to inherit the connection region. - `--proxy-id ` / `--proxy-name ` / `--proxy-mode direct|default` - Proxy override for this login's browser session (mutually exclusive); omitted properties inherit the connection defaults - `--stealth` - Stealth override for this login's browser session; use `--stealth=false` to disable - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Telemetry override for this login only, merged onto the connection's config diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index af052b14..1b1dc6d0 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -1363,7 +1363,7 @@ func init() { authConnectionsCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for this connection's browser sessions (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsCreateCmd.Flags().String("proxy-name", "", "Proxy name to use for this connection's browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the stealth-derived default") - authConnectionsCreateCmd.Flags().String("region", "", "Geographic region for browser sessions: 'us-east', 'eu-west', or 'ap-southeast'. Defaults to us-east") + authConnectionsCreateCmd.Flags().String("region", "", "Geographic region for browser sessions: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'. Defaults to us-east") authConnectionsCreateCmd.Flags().Bool("stealth", true, "Run this connection's browser sessions in stealth mode; use --stealth=false to disable") authConnectionsCreateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks. Defaults to 3600 or your plan minimum, whichever is larger. The maximum is 86400; the minimum depends on your plan (Enterprise 300, Startup 1200, Hobbyist 3600, Free 21600)") @@ -1392,7 +1392,7 @@ func init() { authConnectionsUpdateCmd.Flags().String("proxy-id", "", "Proxy ID to use for future browser sessions (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsUpdateCmd.Flags().String("proxy-name", "", "Proxy name to use for future browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsUpdateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' to drop a selected proxy and use the stealth-derived default") - authConnectionsUpdateCmd.Flags().String("region", "", "Geographic region for future browser sessions: 'us-east', 'eu-west', or 'ap-southeast'") + authConnectionsUpdateCmd.Flags().String("region", "", "Geographic region for future browser sessions: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'") authConnectionsUpdateCmd.Flags().Bool("stealth", true, "Set whether future browser sessions run in stealth mode; use --stealth=false to disable") authConnectionsUpdateCmd.Flags().Bool("save-credentials", false, "Enable saving credentials after successful login") authConnectionsUpdateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") @@ -1427,7 +1427,7 @@ func init() { authConnectionsLoginCmd.Flags().String("proxy-id", "", "Proxy ID to use for this login (mutually exclusive with --proxy-name and --proxy-mode)") authConnectionsLoginCmd.Flags().String("proxy-name", "", "Proxy name to use for this login (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsLoginCmd.Flags().String("proxy-mode", "", "Proxy egress mode for this login instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the stealth-derived default") - authConnectionsLoginCmd.Flags().String("region", "", "Geographic region override for this login: 'us-east', 'eu-west', or 'ap-southeast'") + authConnectionsLoginCmd.Flags().String("region", "", "Geographic region override for this login: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'") authConnectionsLoginCmd.Flags().Bool("stealth", true, "Override stealth mode for this login's browser session; use --stealth=false to disable") authConnectionsLoginCmd.Flags().Bool("record-session", false, "Override whether this login's browser session is recorded; use --record-session=false to disable") authConnectionsLoginCmd.Flags().String("skill-mode", "", "Whether this login reads and writes learned domain skills: 'enabled' (default) or 'disabled'. Automatic reauths inherit the selected mode until a later accepted login sets enabled or omits the flag") diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 18ffba7d..05b9b7e5 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -793,7 +793,7 @@ func init() { browserPoolsListCmd.Flags().String("query", "", "Search browser pools by name (IDs match by exact value)") browserPoolsListCmd.Flags().Int("limit", 0, "Maximum number of pools to return") browserPoolsListCmd.Flags().Int("offset", 0, "Number of pools to skip (for pagination)") - browserPoolsListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'eu-west', or 'ap-southeast' (omit to list pools in all regions)") + browserPoolsListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'us-west', 'eu-west', or 'ap-southeast' (omit to list pools in all regions)") addJSONOutputFlag(browserPoolsCreateCmd) browserPoolsCreateCmd.Flags().String("name", "", "Optional unique name for the pool") @@ -809,7 +809,7 @@ func init() { browserPoolsCreateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsCreateCmd.Flags().String("profile-name", "", "Profile name") browserPoolsCreateCmd.Flags().String("proxy-id", "", "Proxy ID") - browserPoolsCreateCmd.Flags().String("region", "", "Geographic region for the pool: 'us-east', 'eu-west', or 'ap-southeast'. Fixed once the pool is created; requires a Start-Up or Enterprise plan and defaults to us-east") + browserPoolsCreateCmd.Flags().String("region", "", "Geographic region for the pool: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'. Fixed once the pool is created; requires a Start-Up or Enterprise plan and defaults to us-east") browserPoolsCreateCmd.Flags().StringSlice("private-host", nil, "Destinations browsers in the pool reach directly through their own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel they join (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them") browserPoolsCreateCmd.Flags().String("start-url", "", "Initial page to open for new browsers") browserPoolsCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") diff --git a/cmd/browsers.go b/cmd/browsers.go index 51e06750..cd07282d 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -166,7 +166,7 @@ func parseViewport(viewport string) (width, height, refreshRate int64, err error // availableRegions returns the geographic regions the API accepts for browser // sessions and pools. func availableRegions() []string { - return []string{"us-east", "eu-west", "ap-southeast"} + return []string{"us-east", "us-west", "eu-west", "ap-southeast"} } // parseRegionFlag validates a --region value. An empty value means the flag was @@ -2956,7 +2956,7 @@ func init() { browsersListCmd.Flags().Int("limit", 0, "Maximum number of results to return (default 20, max 100)") browsersListCmd.Flags().Int("offset", 0, "Number of results to skip (for pagination)") browsersListCmd.Flags().String("query", "", "Search browsers by name, session ID, profile ID, proxy ID, or pool name") - browsersListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'eu-west', or 'ap-southeast' (omit to list sessions in all regions)") + browsersListCmd.Flags().String("region", "", "Filter by geographic region: 'us-east', 'us-west', 'eu-west', or 'ap-southeast' (omit to list sessions in all regions)") browsersListCmd.Flags().StringArray("tag", nil, "Filter by tag KEY=VALUE (repeatable; a session must match every pair)") // get flags @@ -3280,7 +3280,7 @@ unrestricted code execution inside the browser VM and is not sandboxed.`, browsersCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for the browser session (mutually exclusive with --proxy-name and --proxy-mode)") browsersCreateCmd.Flags().String("proxy-name", "", "Proxy name to use for the browser session; must match exactly one active proxy in the project (mutually exclusive with --proxy-id and --proxy-mode)") browsersCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the browser default (Kernel's stealth proxy when --stealth is set, direct egress otherwise)") - browsersCreateCmd.Flags().String("region", "", "Geographic region for the session: 'us-east', 'eu-west', or 'ap-southeast'. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to us-east") + browsersCreateCmd.Flags().String("region", "", "Geographic region for the session: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to us-east") browsersCreateCmd.Flags().StringSlice("private-host", nil, "Destinations the browser reaches directly through its own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them. Fixed once the session is created") browsersCreateCmd.Flags().StringSlice("allowed-host", nil, "Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeat or comma-separated, max 100); anything else is refused with a 403 (network_policy_denied). Accepts exact hostnames ('example.com'), a leading wildcard matching subdomains only ('*.example.com'), public IPs ('8.8.8.8', '[2001:4860:4860::8888]'), and public CIDRs ('8.8.4.0/24'). No ports, paths, or schemes. --start-url must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Create-only") browsersCreateCmd.Flags().StringArray("proxy-route", nil, "Route HOST[,HOST...]=PROXY through a proxy (repeatable, max 10 routes and 50 hosts per route). PROXY is an ID by default; use id:ID or name:NAME explicitly. Exact hosts beat wildcards (longer suffixes win); *.example.com excludes example.com. Unmatched hosts use --proxy-* or default egress; start_url uses the top-level proxy. Create-only") diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 6cb0036c..76b8adbc 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -777,6 +777,10 @@ func TestBrowsersCreate_WithRegion(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(raw), `"region":"ap-southeast"`) + err = b.Create(context.Background(), BrowsersCreateInput{Region: "us-west"}) + require.NoError(t, err) + assert.Equal(t, kernel.BrowserNewParamsRegionUsWest, captured.Region) + // Omitting the flag sends nothing; the server defaults to us-east. err = b.Create(context.Background(), BrowsersCreateInput{}) require.NoError(t, err) diff --git a/go.mod b/go.mod index b804426f..8ee2d351 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.119.1-0.20261006192232-418cdfed18c8 + github.com/kernel/kernel-go-sdk v0.119.1-0.20261006201303-218107bfe6a5 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 f393e8db..410c055d 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006192232-418cdfed18c8 h1:g1zA+KLA3DXNHY3rZwr0zZPGUeC6X7PWru0qnWc820I= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006192232-418cdfed18c8/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006201303-218107bfe6a5 h1:Qxgno4N1f2WIgS/VzbuUpGkEmptu2M6sVm2yVF0ebhM= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006201303-218107bfe6a5/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 f342182866b306cb824a9cfb5d864b992ac1ece2 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:42:33 +0000 Subject: [PATCH 16/20] CLI: Update SDK to 0f34ffb9d53a and add Playwright executor commands/flags Update kernel-go-sdk to 0f34ffb9d53a87043f20e0e8605c057183849299 and cover the named Playwright executors surface (ported from kernel/cli#281, using the published SDK instead of a staging replace directive): - `kernel browsers playwright execute --executor ` (BrowserPlaywrightExecuteParams.Executor); table output shows the bound tab - `kernel browsers playwright executors list ` (client.Browsers.Playwright.Executors.List) - `kernel browsers playwright executors delete [--close-tab]` (client.Browsers.Playwright.Executors.Delete, CloseTab param) Tested: browsers playwright execute --executor (new + reused tab, -o json), browsers playwright executors list (table + json), browsers playwright executors delete (named, --close-tab=false, default restart) Co-Authored-By: Claude Opus 5.5 --- README.md | 13 + cmd/browsers.go | 21 +- cmd/browsers_playwright_executors.go | 182 +++++++++++++ cmd/browsers_playwright_executors_test.go | 316 ++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 6 files changed, 526 insertions(+), 12 deletions(-) create mode 100644 cmd/browsers_playwright_executors.go create mode 100644 cmd/browsers_playwright_executors_test.go diff --git a/README.md b/README.md index 89e3df0f..a2c29af7 100644 --- a/README.md +++ b/README.md @@ -1021,8 +1021,12 @@ Destinations are the OTLP/HTTP endpoints sessions export to. They belong to the ### Browser Playwright - `kernel browsers playwright execute [code]` - Execute Playwright/TypeScript code against the browser + - `--executor ` - Executor to run the call in. Calls on different executors run concurrently in separate tabs of the same browser; calls on one executor run one at a time. Omit to use the always-present `default` executor bound to the active tab. Any other name creates a named executor on first use that owns a background tab `page` is bound to. At most 8 named executors per browser (409 when exceeded) - `--timeout ` - Maximum execution time in seconds (defaults server-side) - If `[code]` is omitted, code is read from stdin +- `kernel browsers playwright executors list ` - List the browser's Playwright executors (default first) with busy state, timestamps, and the tab each named executor owns +- `kernel browsers playwright executors delete ` - Delete a Playwright executor. Deleting `default` restarts it instead of removing it + - `--close-tab` - Close the tab owned by the executor (default: true) ### Browser REPL @@ -1536,6 +1540,15 @@ TS # With a timeout in seconds kernel browsers playwright execute my-browser --timeout 30 'await (await context.newPage()).goto("https://example.com")' +# Drive two tabs in parallel with named executors (each owns its own tab) +kernel browsers playwright execute my-browser --executor docs 'await page.goto("https://example.com/docs"); return await page.title();' & +kernel browsers playwright execute my-browser --executor pricing 'await page.goto("https://example.com/pricing"); return await page.title();' & +wait + +# List executors and delete one (closes its tab unless --close-tab=false) +kernel browsers playwright executors list my-browser +kernel browsers playwright executors delete my-browser docs + # Mini CDP connection load test (10s) cat <<'TS' | kernel browsers playwright execute my-browser const start = Date.now(); diff --git a/cmd/browsers.go b/cmd/browsers.go index cd07282d..a44c399d 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -548,6 +548,7 @@ type BrowsersCmd struct { logs BrowserLogService computer BrowserComputerService playwright BrowserPlaywrightService + executors BrowserPlaywrightExecutorService telemetry BrowserTelemetryService webmcp BrowserWebMCPService } @@ -1869,6 +1870,7 @@ type BrowsersFSWatchEventsInput struct { type BrowsersPlaywrightExecuteInput struct { Identifier string Code string + Executor string Timeout int64 Output string } @@ -1887,12 +1889,15 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh return util.CleanedUpSdkError{Err: err} } params := kernel.BrowserPlaywrightExecuteParams{Code: in.Code} + if in.Executor != "" { + params.Executor = kernel.Opt(in.Executor) + } if in.Timeout > 0 { params.TimeoutSec = kernel.Opt(in.Timeout) } res, err := b.playwright.Execute(ctx, br.SessionID, params) if err != nil { - return util.CleanedUpSdkError{Err: err} + return playwrightExecuteError(err) } if in.Output == "json" { @@ -1900,6 +1905,9 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh } rows := pterm.TableData{{"Property", "Value"}, {"Success", fmt.Sprintf("%t", res.Success)}} + if res.JSON.Tab.Valid() { + rows = append(rows, []string{"Tab Target ID", res.Tab.TargetID}, []string{"Tab Created", fmt.Sprintf("%t", res.Tab.Created)}) + } PrintTableNoPad(rows, true) if res.Stdout != "" { @@ -3230,13 +3238,7 @@ func init() { computerRoot.AddCommand(computerClick, computerMove, computerScreenshot, computerType, computerPressKey, computerScroll, computerDrag, computerSetCursor, computerGetMousePosition, computerBatch, computerReadClipboard, computerWriteClipboard) browsersCmd.AddCommand(computerRoot) - // playwright - playwrightRoot := &cobra.Command{Use: "playwright", Short: "Playwright operations"} - playwrightExecute := &cobra.Command{Use: "execute [code]", Short: "Execute Playwright/TypeScript code against the browser", Args: cobra.MinimumNArgs(1), RunE: runBrowsersPlaywrightExecute} - playwrightExecute.Flags().Int64("timeout", 0, "Maximum execution time in seconds (default per server)") - addJSONOutputFlag(playwrightExecute) - playwrightRoot.AddCommand(playwrightExecute) - browsersCmd.AddCommand(playwrightRoot) + browsersCmd.AddCommand(newBrowsersPlaywrightCommand()) // repl replCmd := &cobra.Command{ @@ -3881,10 +3883,11 @@ func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { } code = string(data) } + executor, _ := cmd.Flags().GetString("executor") timeout, _ := cmd.Flags().GetInt64("timeout") output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, playwright: &svc.Playwright} - return b.PlaywrightExecute(cmd.Context(), BrowsersPlaywrightExecuteInput{Identifier: args[0], Code: strings.TrimSpace(code), Timeout: timeout, Output: output}) + return b.PlaywrightExecute(cmd.Context(), BrowsersPlaywrightExecuteInput{Identifier: args[0], Code: strings.TrimSpace(code), Executor: executor, Timeout: timeout, Output: output}) } func runBrowsersRepl(cmd *cobra.Command, args []string) error { diff --git a/cmd/browsers_playwright_executors.go b/cmd/browsers_playwright_executors.go new file mode 100644 index 00000000..ae44f6a8 --- /dev/null +++ b/cmd/browsers_playwright_executors.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/param" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// BrowserPlaywrightExecutorService defines the subset we use for Playwright executors. +type BrowserPlaywrightExecutorService interface { + List(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.ExecutorList, error) + Delete(ctx context.Context, name string, params kernel.BrowserPlaywrightExecutorDeleteParams, opts ...option.RequestOption) error +} + +const playwrightExecutorsLong = `Every Playwright call runs in an executor: a dedicated process with its own +browser connection. Calls on different executors run concurrently; calls on +one executor run one at a time. A timeout or crash in one executor does not +affect the others. + +The 'default' executor always exists and binds 'page' to the active tab. Any +other name is a named executor: the first call with a new name creates it, and +it owns a background tab in the default browser context that 'page' is bound +to on every later call. Executor code can still reach other tabs through +'context' and 'browser'. Use named executors to drive several tabs of one +browser in parallel. + +A browser can have at most 8 named executors (the default executor does not +count); a call that would create another fails with HTTP 409. Named executors +are not removed automatically while the browser runs, so delete the ones you no +longer need.` + +type BrowsersPlaywrightExecutorsListInput struct { + Identifier string + Output string +} + +type BrowsersPlaywrightExecutorsDeleteInput struct { + Identifier string + Name string + CloseTab param.Opt[bool] +} + +func (b BrowsersCmd) PlaywrightExecutorsList(ctx context.Context, in BrowsersPlaywrightExecutorsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + res, err := b.executors.List(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + if len(res.Executors) == 0 { + pterm.Info.Println("No Playwright executors found") + return nil + } + rows := pterm.TableData{{"Name", "Busy", "Created At", "Last Used At", "Target ID", "URL"}} + for _, e := range res.Executors { + rows = append(rows, []string{e.Name, strconv.FormatBool(e.Busy), util.FormatLocal(e.CreatedAt), util.FormatLocal(e.LastUsedAt), util.OrDash(e.TargetID), util.OrDash(e.URL)}) + } + PrintTableNoPad(rows, true) + return nil +} + +func (b BrowsersCmd) PlaywrightExecutorsDelete(ctx context.Context, in BrowsersPlaywrightExecutorsDeleteInput) error { + params := kernel.BrowserPlaywrightExecutorDeleteParams{IDOrName: in.Identifier, CloseTab: in.CloseTab} + if err := b.executors.Delete(ctx, in.Name, params); err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Name == "default" { + pterm.Success.Println("Restarted the default Playwright executor") + } else { + pterm.Success.Printf("Deleted Playwright executor %q\n", in.Name) + } + return nil +} + +// playwrightExecuteError turns the 409 returned when a call would exceed the +// named executor limit into an error that names the current executors. +func playwrightExecuteError(err error) error { + var apiErr *kernel.Error + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusConflict { + return util.CleanedUpSdkError{Err: err} + } + var body struct { + Message string `json:"message"` + Executors []kernel.Executor `json:"executors"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) != nil || body.Message == "" { + return util.CleanedUpSdkError{Err: err} + } + var sb strings.Builder + sb.WriteString(body.Message) + if len(body.Executors) > 0 { + sb.WriteString("\nCurrent executors:") + for _, e := range body.Executors { + fmt.Fprintf(&sb, "\n %s", e.Name) + if e.Busy { + sb.WriteString(" (busy)") + } + if e.URL != "" { + fmt.Fprintf(&sb, " %s", e.URL) + } + } + } + sb.WriteString("\nDelete one with 'kernel browsers playwright executors delete '") + return errors.New(sb.String()) +} + +func newBrowsersPlaywrightCommand() *cobra.Command { + root := &cobra.Command{Use: "playwright", Short: "Playwright operations"} + execute := &cobra.Command{ + Use: "execute [code]", + Short: "Execute Playwright/TypeScript code against the browser", + Long: "Execute Playwright/TypeScript code against the browser.\n\n" + + "Code may be passed as an argument or piped via stdin. It has access to 'page', " + + "'context', and 'browser', and may return a value.\n\n" + playwrightExecutorsLong, + Args: cobra.MinimumNArgs(1), + RunE: runBrowsersPlaywrightExecute, + } + execute.Flags().String("executor", "", "Executor to run the call in. Calls on different executors run concurrently in separate tabs of the same browser; calls on one executor run one at a time. Omit to use the always-present 'default' executor bound to the active tab. Any other name creates a named executor on first use that owns a background tab 'page' is bound to. At most 8 named executors per browser (409 when exceeded)") + execute.Flags().Int64("timeout", 0, "Maximum execution time in seconds (default per server)") + addJSONOutputFlag(execute) + root.AddCommand(execute, newBrowsersPlaywrightExecutorsCommand()) + return root +} + +func newBrowsersPlaywrightExecutorsCommand() *cobra.Command { + root := &cobra.Command{Use: "executors", Short: "List and delete the Playwright executors of a browser", Long: playwrightExecutorsLong} + list := &cobra.Command{ + Use: "list ", + Short: "List the browser's Playwright executors", + Long: "List the browser's Playwright executors, the default executor first. Each entry reports " + + "whether a call is running on it and, for named executors, the target ID and URL of the tab it owns.", + Args: cobra.ExactArgs(1), + RunE: runBrowsersPlaywrightExecutorsList, + } + addJSONOutputFlag(list) + del := &cobra.Command{ + Use: "delete ", + Short: "Delete a Playwright executor and, by default, close its tab", + Long: "Stop a Playwright executor's process and, by default, close the tab it owns. A call running " + + "on it fails with an error saying the executor was deleted; the name can be reused afterwards.\n\n" + + "Deleting 'default' restarts it instead of removing it: queued and later calls run on a new " + + "process. It owns no tab, so --close-tab has no effect on it.", + Args: cobra.ExactArgs(2), + RunE: runBrowsersPlaywrightExecutorsDelete, + } + del.Flags().Bool("close-tab", true, "Close the tab owned by the executor") + root.AddCommand(list, del) + return root +} + +func runBrowsersPlaywrightExecutorsList(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + client := getKernelClient(cmd) + b := BrowsersCmd{executors: &client.Browsers.Playwright.Executors} + return b.PlaywrightExecutorsList(cmd.Context(), BrowsersPlaywrightExecutorsListInput{Identifier: args[0], Output: output}) +} + +func runBrowsersPlaywrightExecutorsDelete(cmd *cobra.Command, args []string) error { + var closeTab param.Opt[bool] + if cmd.Flags().Changed("close-tab") { + value, _ := cmd.Flags().GetBool("close-tab") + closeTab = kernel.Opt(value) + } + client := getKernelClient(cmd) + b := BrowsersCmd{executors: &client.Browsers.Playwright.Executors} + return b.PlaywrightExecutorsDelete(cmd.Context(), BrowsersPlaywrightExecutorsDeleteInput{Identifier: args[0], Name: args[1], CloseTab: closeTab}) +} diff --git a/cmd/browsers_playwright_executors_test.go b/cmd/browsers_playwright_executors_test.go new file mode 100644 index 00000000..9e37772a --- /dev/null +++ b/cmd/browsers_playwright_executors_test.go @@ -0,0 +1,316 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func executePlaywrightCommand(t *testing.T, handler http.HandlerFunc, args ...string) (string, string, error) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test"), option.WithMaxRetries(0)) + root := &cobra.Command{Use: "kernel", SilenceErrors: true, SilenceUsage: true} + root.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client)) + root.AddCommand(newBrowsersPlaywrightCommand()) + root.SetArgs(append([]string{"playwright"}, args...)) + buf := capturePtermOutput(t) + var err error + stdout := captureStdout(t, func() { err = root.Execute() }) + return stdout, buf.String(), err +} + +const playwrightExecutorsFixture = `{"executors":[{"name":"default","busy":false,"created_at":"2026-01-02T03:04:05Z","last_used_at":"2026-01-02T03:05:05Z"},{"name":"checkout","busy":true,"created_at":"2026-01-02T03:06:05Z","last_used_at":"2026-01-02T03:07:05Z","target_id":"ABCDEF0123456789","url":"https://example.com/cart"}]}` + +func TestPlaywrightCommandWiring(t *testing.T) { + for _, path := range [][]string{{"execute"}, {"executors", "list"}, {"executors", "delete"}} { + name := path[len(path)-1] + cmd, remaining, err := rootCmd.Find(append([]string{"browsers", "playwright"}, path...)) + require.NoError(t, err) + require.Empty(t, remaining) + assert.Equal(t, name, cmd.Name()) + assert.NotNil(t, cmd.RunE) + assert.False(t, isAuthExempt(cmd)) + } + + execute, _, err := rootCmd.Find([]string{"browsers", "playwright", "execute"}) + require.NoError(t, err) + executor := execute.Flags().Lookup("executor") + require.NotNil(t, executor) + assert.Equal(t, "", executor.DefValue) + assert.Contains(t, executor.Usage, "default") + assert.Contains(t, executor.Usage, "8 named executors") + assert.NotNil(t, execute.Flags().Lookup("timeout")) + assert.NotNil(t, execute.Flags().Lookup("output")) + + del, _, err := rootCmd.Find([]string{"browsers", "playwright", "executors", "delete"}) + require.NoError(t, err) + closeTab := del.Flags().Lookup("close-tab") + require.NotNil(t, closeTab) + assert.Equal(t, "true", closeTab.DefValue) + assert.Contains(t, del.Long, "default") + + list, _, err := rootCmd.Find([]string{"browsers", "playwright", "executors", "list"}) + require.NoError(t, err) + assert.NotNil(t, list.Flags().Lookup("output")) +} + +func TestPlaywrightExecuteExecutorParam(t *testing.T) { + for _, tc := range []struct { + name string + flags []string + executor string + }{ + {"omitted", nil, ""}, + {"named", []string{"--executor", "checkout"}, "checkout"}, + } { + t.Run(tc.name, func(t *testing.T) { + var body struct { + Code string `json:"code"` + Executor *string `json:"executor"` + Timeout *int64 `json:"timeout_sec"` + } + calls := 0 + stdout, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/browsers/my-browser": + fmt.Fprint(w, `{"session_id":"session123"}`) + case r.Method == http.MethodPost && r.URL.Path == "/browsers/session123/playwright/execute": + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + fmt.Fprint(w, `{"success":true,"result":{"title":"Example"},"tab":{"target_id":"ABCDEF0123456789","created":true}}`) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + }, append([]string{"execute", "my-browser", "return await page.title()", "--timeout", "30"}, tc.flags...)...) + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.Equal(t, "return await page.title()", body.Code) + if tc.executor == "" { + assert.Nil(t, body.Executor) + } else { + require.NotNil(t, body.Executor) + assert.Equal(t, tc.executor, *body.Executor) + } + require.NotNil(t, body.Timeout) + assert.Equal(t, int64(30), *body.Timeout) + for _, value := range []string{"Success", "true", "Tab Target ID", "ABCDEF0123456789", "Tab Created"} { + assert.Contains(t, table, value) + } + assert.Contains(t, stdout, `"title": "Example"`) + }) + } +} + +func TestPlaywrightExecuteOutput(t *testing.T) { + for _, tc := range []struct { + name string + response string + json bool + }{ + {"without tab", `{"success":false,"error":"boom"}`, false}, + {"with tab", `{"success":true,"tab":{"target_id":"ABCDEF0123456789","created":false}}`, false}, + {"json", `{"success":true,"result":42,"tab":{"target_id":"ABCDEF0123456789","created":true}}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + args := []string{"execute", "session123", "return 1"} + if tc.json { + args = append(args, "-o", "json") + } + stdout, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"session_id":"session123"}`) + return + } + fmt.Fprint(w, tc.response) + }, args...) + require.NoError(t, err) + if tc.json { + assert.JSONEq(t, tc.response, stdout) + assert.Empty(t, table) + return + } + if strings.Contains(tc.response, `"tab"`) { + assert.Contains(t, table, "Tab Target ID") + assert.Contains(t, table, "ABCDEF0123456789") + assert.Contains(t, table, "Tab Created") + } else { + assert.NotContains(t, table, "Tab Target ID") + assert.Contains(t, table, "boom") + } + }) + } +} + +func TestPlaywrightExecuteExecutorLimit(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"session_id":"session123"}`) + return + } + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"message":"Browser already has 8 named Playwright executors",`+ + `"executors":[{"name":"default","busy":false,"created_at":"2026-01-02T03:04:05Z","last_used_at":"2026-01-02T03:04:05Z"},`+ + `{"name":"checkout","busy":true,"created_at":"2026-01-02T03:04:05Z","last_used_at":"2026-01-02T03:04:05Z","target_id":"T1","url":"https://example.com/cart"}]}`) + }, "execute", "session123", "return 1", "--executor", "ninth") + require.Error(t, err) + msg := err.Error() + assert.Contains(t, msg, "Browser already has 8 named Playwright executors") + assert.Contains(t, msg, "Current executors:") + assert.Contains(t, msg, "default") + assert.Contains(t, msg, "checkout (busy) https://example.com/cart") + assert.Contains(t, msg, "kernel browsers playwright executors delete") + // The root error handler wraps command errors again before printing them. + assert.Equal(t, msg, util.CleanedUpSdkError{Err: err}.Error()) +} + +func TestPlaywrightExecuteOtherErrors(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + want string + }{ + {"not conflict", http.StatusBadRequest, `{"code":"invalid_request","message":"Invalid executor name"}`, "invalid_request: Invalid executor name"}, + {"conflict without message", http.StatusConflict, `{"code":"conflict","message":""}`, "conflict: "}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"session_id":"session123"}`) + return + } + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + }, "execute", "session123", "return 1", "--executor", "bad name") + require.EqualError(t, err, tc.want) + }) + } +} + +func TestPlaywrightExecutorsList(t *testing.T) { + for _, identifier := range []string{"my-browser", "session123"} { + for _, flags := range [][]string{nil, {"-o", "json"}, {"--output", "json"}} { + t.Run(identifier+strings.Join(flags, ""), func(t *testing.T) { + calls := 0 + stdout, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/browsers/"+identifier+"/playwright/executors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, playwrightExecutorsFixture) + }, append([]string{"executors", "list", identifier}, flags...)...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + if len(flags) > 0 { + assert.JSONEq(t, playwrightExecutorsFixture, stdout) + assert.Empty(t, table) + return + } + for _, value := range []string{"Name", "Busy", "Created At", "Last Used At", "Target ID", "URL", "default", "false", "checkout", "true", "ABCDEF0123456789", "https://example.com/cart"} { + assert.Contains(t, table, value) + } + rows := strings.Split(strings.TrimSpace(table), "\n") + require.Len(t, rows, 3) + assert.Contains(t, rows[1], "default") + assert.Equal(t, 2, strings.Count(rows[1], " - "), rows[1]) + }) + } + } +} + +func TestPlaywrightExecutorsListEmpty(t *testing.T) { + _, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"executors":[]}`) + }, "executors", "list", "my-browser") + require.NoError(t, err) + assert.Contains(t, table, "No Playwright executors found") +} + +func TestPlaywrightExecutorsDelete(t *testing.T) { + for _, tc := range []struct { + name string + executor string + flags []string + closeTab string + want string + }{ + {"default close", "checkout", nil, "", `Deleted Playwright executor "checkout"`}, + {"keep tab", "checkout", []string{"--close-tab=false"}, "false", `Deleted Playwright executor "checkout"`}, + {"explicit close", "checkout", []string{"--close-tab"}, "true", `Deleted Playwright executor "checkout"`}, + {"default executor", "default", nil, "", "Restarted the default Playwright executor"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + stdout, out, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/browsers/my-browser/playwright/executors/"+tc.executor, r.URL.Path) + assert.Equal(t, tc.closeTab, r.URL.Query().Get("close_tab")) + w.WriteHeader(http.StatusNoContent) + }, append([]string{"executors", "delete", "my-browser", tc.executor}, tc.flags...)...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.Empty(t, stdout) + assert.Contains(t, out, tc.want) + }) + } +} + +func TestPlaywrightExecutorsErrors(t *testing.T) { + for _, tc := range []struct { + args []string + status int + body string + want string + }{ + {[]string{"executors", "list", "missing"}, http.StatusNotFound, `{"code":"not_found","message":"Browser not found"}`, "not_found: Browser not found"}, + {[]string{"executors", "delete", "my-browser", "missing"}, http.StatusNotFound, `{"code":"not_found","message":"Executor not found"}`, "not_found: Executor not found"}, + {[]string{"executors", "delete", "my-browser", "bad name"}, http.StatusBadRequest, `{"code":"invalid_request","message":"Invalid executor name"}`, "invalid_request: Invalid executor name"}, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + }, tc.args...) + require.EqualError(t, err, tc.want) + }) + } +} + +func TestPlaywrightExecutorsInvalidInput(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"executors", "list"}, "accepts 1 arg"}, + {[]string{"executors", "list", "browser", "-o", "yaml"}, "unsupported --output"}, + {[]string{"executors", "delete", "browser"}, "accepts 2 arg"}, + {[]string{"executors", "delete", "browser", "a", "b"}, "accepts 2 arg"}, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("invalid input reached API") + }, tc.args...) + require.ErrorContains(t, err, tc.want) + }) + } +} diff --git a/go.mod b/go.mod index 8ee2d351..9405ca2b 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.119.1-0.20261006201303-218107bfe6a5 + github.com/kernel/kernel-go-sdk v0.119.1-0.20261006203430-0f34ffb9d53a 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 410c055d..af8b7f9d 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006201303-218107bfe6a5 h1:Qxgno4N1f2WIgS/VzbuUpGkEmptu2M6sVm2yVF0ebhM= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006201303-218107bfe6a5/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006203430-0f34ffb9d53a h1:L0e7EvDKpOLRJXAHlQp1RxzV+cqG4vc56CcskV+x8j0= +github.com/kernel/kernel-go-sdk v0.119.1-0.20261006203430-0f34ffb9d53a/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 6361a5891dc17a7cb5e6d77ed86b212b9a3a927a Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:50:17 +0000 Subject: [PATCH 17/20] CLI: Update Go SDK to 503fb616618f159e2797f266afa239ca1e8f2828 (v0.120.0) SDK diff since 0f34ffb9d53a is a release-only bump (version/changelog), with no API surface changes. Full enumeration of api.md vs CLI commands found no coverage gaps (config-registry endpoints are x-cli-skip). Tested: go build ./..., go vet ./..., go test ./... (all pass) Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9405ca2b..fd049025 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.119.1-0.20261006203430-0f34ffb9d53a + github.com/kernel/kernel-go-sdk v0.120.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index af8b7f9d..27c84c37 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006203430-0f34ffb9d53a h1:L0e7EvDKpOLRJXAHlQp1RxzV+cqG4vc56CcskV+x8j0= -github.com/kernel/kernel-go-sdk v0.119.1-0.20261006203430-0f34ffb9d53a/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.120.0 h1:m4E5OPcv3jZfODi+zP5waICXL1fvcoBD0xdvDAAP4hM= +github.com/kernel/kernel-go-sdk v0.120.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From d475d8ddd145805bb9750ed7c9cab56b23e21e9b Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:35:32 +0000 Subject: [PATCH 18/20] CLI: Update Go SDK to b71ecfcbaeec and add browsers update --allowed-host Update kernel-go-sdk to b71ecfcbaeecb29db2b273575df0e7d96e9533f6. New flags on `kernel browsers update` (BrowserUpdateParams.Network.AllowedHosts): - --allowed-host: replace a running session's egress allowlist - --clear-allowed-hosts: remove the allowlist (sends allowed_hosts: null) Datacenter proxy deprecation: the SDK dropped the datacenter proxy type. - `proxies create --type datacenter` still works (the API still accepts it) but prints a deprecation warning pointing to --type isp - Datacenter is removed from help text, examples, and the README - get/list/check still show the country for existing datacenter proxies Tested: - browsers create --allowed-host example.com, then browsers update --allowed-host example.com,*.wikipedia.org (get shows the new list), --allowed-host with --start-url, --clear-allowed-hosts (allowlist removed), -o json, client-side validation errors, and API validation errors - proxies create/get/list/delete --type isp - proxies create --type datacenter shows the warning and is accepted by API validation (provisioning fails upstream: the provider account is suspended) - go build, go vet, and go test ./... pass Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +++--- cmd/browsers.go | 95 +++++++++++++++++++++++++++---------- cmd/browsers_test.go | 97 ++++++++++++++++++++++++++++++++++++++ cmd/proxies/check.go | 2 +- cmd/proxies/check_test.go | 4 +- cmd/proxies/common_test.go | 10 ++-- cmd/proxies/create.go | 19 +++----- cmd/proxies/create_test.go | 28 ++++++----- cmd/proxies/get.go | 2 +- cmd/proxies/get_test.go | 2 +- cmd/proxies/list.go | 2 +- cmd/proxies/proxies.go | 14 ++---- cmd/proxies/types.go | 6 ++- cmd/proxies/update_test.go | 2 +- go.mod | 2 +- go.sum | 4 +- 16 files changed, 221 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index a2c29af7..9587713a 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `--kiosk` - Launch browser in kiosk mode - `--region us-east|us-west|eu-west|ap-southeast` - Geographic region for the session. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to `us-east`. - `--private-host ` - Destination the browser reaches directly through the session's own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeatable or comma-separated, max 32). Accepts hostname patterns (`*.example.ts.net`), IPs (`10.1.30.63`, `[fd00::1]`), and private CIDRs (`100.64.0.0/10`). Replaces the default private ranges (RFC1918, `100.64.0.0/10`, `fc00::/7`); omit to keep them. Fixed once the session is created. Unrelated to a proxy's `--bypass-host`, which only chooses between upstream proxy and Kernel-managed direct egress. - - `--allowed-host ` - Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeatable or comma-separated, max 100). Other destinations are refused with a 403 whose `X-Kernel-Proxy-Error` header is `network_policy_denied`. Accepts exact hostnames (`example.com`), a leading wildcard that matches subdomains but not the domain itself (`*.example.com`), public IPs (`8.8.8.8`, `[2001:4860:4860::8888]`), and public CIDRs (`8.8.4.0/24`); no ports, paths, or schemes. `--start-url` must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Create-only. + - `--allowed-host ` - Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeatable or comma-separated, max 100). Other destinations are refused with a 403 whose `X-Kernel-Proxy-Error` header is `network_policy_denied`. Accepts exact hostnames (`example.com`), a leading wildcard that matches subdomains but not the domain itself (`*.example.com`), public IPs (`8.8.8.8`, `[2001:4860:4860::8888]`), and public CIDRs (`8.8.4.0/24`); no ports, paths, or schemes. `--start-url` must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Can be replaced or removed later with `browsers update --allowed-host` / `--clear-allowed-hosts`. - `--proxy-route '[,...]='` - Route matching browser requests through a selected proxy (repeatable, max 10 routes with 1–50 hosts each). Example: `--proxy-route 'api.ipify.org,*.ipify.org=name:my-dc-proxy'`. The proxy is an ID by default; use `id:` or `name:` explicitly. Exact hostnames beat wildcards; longer wildcard suffixes beat shorter ones. `*.example.com` matches subdomains, not `example.com`. Matching ignores case and ports. Unmatched hosts use `--proxy-*` or default egress, while `--start-url` uses the top-level proxy during setup. Routes are create-only and are not available on pool sessions. - `--start-url ` - Initial page to open on launch - `--proxy-id ` / `--proxy-name ` - Use that proxy for the session regardless of stealth (mutually exclusive with each other and with `--proxy-mode`) @@ -304,6 +304,8 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `--proxy-mode direct|default` - Change egress mode: `direct` for no proxy regardless of stealth, `default` to restore the browser default after using a selected proxy. Changing the proxy does not change stealth or CAPTCHA solver behavior. - `--clear-proxy` - Drop the selected proxy and restore the browser default (same as `--proxy-mode=default`) - `--disable-default-proxy` - Connect directly instead of through the default stealth proxy (same as `--proxy-mode=direct`); use `--disable-default-proxy=false` to restore the default + - `--allowed-host ` - Replace the egress allowlist (repeatable or comma-separated, max 100), using the same entry rules as `browsers create --allowed-host`. Applies without restarting the browser: new requests to destinations no longer allowed are refused within a few seconds and open connections to them are closed within about 30 seconds. `--start-url` in the same update must be allowed by the new list. Requires a browser created with proxy v3; not supported on pooled browsers. Mutually exclusive with `--clear-allowed-hosts` + - `--clear-allowed-hosts` - Remove the egress allowlist and return to unfiltered egress - `--output json`, `-o json` - Output raw JSON object - `kernel browsers curl ` - Make HTTP requests through a browser session's Chrome network stack - `-X, --request ` - HTTP method (default: GET; defaults to POST when `--data` is set) @@ -1147,7 +1149,7 @@ anywhere a project ID does. - `--output json`, `-o json` - Output raw JSON object - `--name ` - Proxy configuration name (required) - - `--type ` - Proxy type: datacenter, isp, residential, mobile, custom (required) + - `--type ` - Proxy type: isp, residential, mobile, custom (required) - `--protocol ` - Protocol to use (default: https) - `--country ` - ISO 3166 country code or "EU" (location-based types) - `--city ` - City name (no spaces, e.g. sanfrancisco) (residential, mobile; requires `--country`) @@ -1614,11 +1616,11 @@ kernel browsers extensions upload my-browser ./extension1 ./extension2 # List proxy configurations kernel proxies list -# Create a datacenter proxy -kernel proxies create --type datacenter --country US --name "US Datacenter" +# Create an ISP proxy +kernel proxies create --type isp --country US --name "US ISP" -# Create a datacenter proxy using HTTP protocol -kernel proxies create --type datacenter --country US --protocol http --name "US DC (HTTP)" +# Create an ISP proxy using HTTP protocol +kernel proxies create --type isp --country US --protocol http --name "US ISP (HTTP)" # Create a custom proxy kernel proxies create --type custom --host proxy.example.com --port 8080 --username myuser --password mypass --name "My Custom Proxy" diff --git a/cmd/browsers.go b/cmd/browsers.go index a44c399d..5e4672f9 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -23,6 +23,7 @@ import ( "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/kernel/kernel-go-sdk/packages/param" "github.com/kernel/kernel-go-sdk/packages/ssestream" "github.com/kernel/kernel-go-sdk/shared" "github.com/pterm/pterm" @@ -535,7 +536,12 @@ type BrowsersUpdateInput struct { TagsProvided bool ClearTags bool StartURL string - Output string + // AllowedHosts replaces the session's egress allowlist when + // AllowedHostsProvided is set; ClearAllowedHosts removes it. + AllowedHosts []string + AllowedHostsProvided bool + ClearAllowedHosts bool + Output string } // BrowsersCmd is a cobra-independent command handler for browsers operations. @@ -1089,6 +1095,20 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { hasTagsChange := len(in.Tags) > 0 || in.ClearTags hasStartURLChange := in.StartURL != "" + // The API replaces the allowlist with the given entries, or removes it when + // sent null. An empty list is rejected, so require at least one entry. + if in.AllowedHostsProvided && in.ClearAllowedHosts { + return fmt.Errorf("cannot specify both --allowed-host and --clear-allowed-hosts") + } + allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts) + if err != nil { + return err + } + if in.AllowedHostsProvided && len(allowedHosts) == 0 { + return fmt.Errorf("at least one --allowed-host entry is required; use --clear-allowed-hosts to remove the allowlist") + } + hasAllowedHostsChange := len(allowedHosts) > 0 || in.ClearAllowedHosts + // Validate --save-changes is only used with a profile if in.ProfileSaveChanges.Set && !hasProfileChange { return fmt.Errorf("--save-changes requires --profile-id or --profile-name") @@ -1100,8 +1120,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 && !hasStartURLChange { - 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, --clear-tags, or --start-url") + if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && in.TelemetryCdpExclude == "" && !hasNameChange && !hasTagsChange && !hasStartURLChange && !hasAllowedHostsChange { + 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, --clear-tags, --start-url, --allowed-host, or --clear-allowed-hosts") } params := kernel.BrowserUpdateParams{} @@ -1127,6 +1147,13 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { params.StartURL = kernel.String(in.StartURL) } + // Handle egress allowlist changes. Null removes the allowlist. + if in.ClearAllowedHosts { + params.Network.AllowedHosts = param.NullSlice[[]string]() + } else if len(allowedHosts) > 0 { + params.Network.AllowedHosts = allowedHosts + } + // Handle proxy changes if hasProxyChange { proxy, err := buildProxyConfigParam(proxySel) @@ -1204,6 +1231,9 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if hasStartURLChange { pterm.Info.Printf("Start URL: %s\n", util.OrDash(browser.StartURL)) } + if hasAllowedHostsChange { + pterm.Info.Printf("Allowed Hosts: %s\n", formatAllowedHosts(browser.Network)) + } if in.Telemetry != "" || in.TelemetryCdpExclude != "" { printTelemetrySummary(browser.Telemetry) } @@ -2938,12 +2968,18 @@ Supported operations: - Rename or clear the session name (--name or --clear-name) - Replace or clear the session tags (--tag or --clear-tags) - Navigate the session to a URL (--start-url) + - Replace or remove the egress allowlist (--allowed-host or --clear-allowed-hosts) Notes: - Profiles can only be loaded into sessions that don't already have a profile. - --start-url navigation is best-effort: the update succeeds even if the page fails to load. - --start-url combined with --profile-id/--profile-name overrides the profile's restored tabs. - - --tag replaces the entire tag set (it is not merged with existing tags).`, + - --tag replaces the entire tag set (it is not merged with existing tags). + - --allowed-host replaces the entire allowlist without restarting the browser. New requests + to destinations no longer allowed are refused within a few seconds, and open connections + to them are closed within about 30 seconds. --start-url must be allowed by the new list. + Requires a browser created with proxy v3; not supported on pooled browsers. If the update + fails, retry it: the new list may already apply to some requests.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return fmt.Errorf("missing required argument: browser ID or name\n\nUsage: kernel browsers update [flags]") @@ -2992,6 +3028,8 @@ func init() { 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") browsersUpdateCmd.Flags().Bool("clear-tags", false, "Remove all tags from the browser session") + browsersUpdateCmd.Flags().StringSlice("allowed-host", nil, "Replace the egress allowlist (repeat or comma-separated, max 100), using the same entry rules as 'browsers create --allowed-host'. Applies without restarting the browser; --start-url must be allowed by the new list. Requires proxy v3; not supported on pooled browsers (mutually exclusive with --clear-allowed-hosts)") + browsersUpdateCmd.Flags().Bool("clear-allowed-hosts", false, "Remove the egress allowlist and return to unfiltered egress") browsersUpdateCmd.Flags().String("start-url", "", "Navigate the browser to this URL after applying the update. Overrides the restored tabs when a profile is loaded in the same update. Navigation is best-effort, so failures do not fail the update") browsersCmd.AddCommand(browsersListCmd) @@ -3284,7 +3322,7 @@ unrestricted code execution inside the browser VM and is not sandboxed.`, 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', 'us-west', 'eu-west', or 'ap-southeast'. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to us-east") browsersCreateCmd.Flags().StringSlice("private-host", nil, "Destinations the browser reaches directly through its own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them. Fixed once the session is created") - browsersCreateCmd.Flags().StringSlice("allowed-host", nil, "Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeat or comma-separated, max 100); anything else is refused with a 403 (network_policy_denied). Accepts exact hostnames ('example.com'), a leading wildcard matching subdomains only ('*.example.com'), public IPs ('8.8.8.8', '[2001:4860:4860::8888]'), and public CIDRs ('8.8.4.0/24'). No ports, paths, or schemes. --start-url must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Create-only") + browsersCreateCmd.Flags().StringSlice("allowed-host", nil, "Egress allowlist: the only destinations the browser may reach through Kernel-managed egress (repeat or comma-separated, max 100); anything else is refused with a 403 (network_policy_denied). Accepts exact hostnames ('example.com'), a leading wildcard matching subdomains only ('*.example.com'), public IPs ('8.8.8.8', '[2001:4860:4860::8888]'), and public CIDRs ('8.8.4.0/24'). No ports, paths, or schemes. --start-url must be allowed. Omit for unfiltered egress. Requires proxy v3; not supported with pools. Change later with 'browsers update --allowed-host'") browsersCreateCmd.Flags().StringArray("proxy-route", nil, "Route HOST[,HOST...]=PROXY through a proxy (repeatable, max 10 routes and 50 hosts per route). PROXY is an ID by default; use id:ID or name:NAME explicitly. Exact hosts beat wildcards (longer suffixes win); *.example.com excludes example.com. Unmatched hosts use --proxy-* or default egress; start_url uses the top-level proxy. Create-only") browsersCreateCmd.Flags().String("start-url", "", "Initial page to open on launch") browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") @@ -3658,31 +3696,36 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { tags, tagsProvided := tagsFromFlag(cmd, "tag") clearTags, _ := cmd.Flags().GetBool("clear-tags") startURL, _ := cmd.Flags().GetString("start-url") + allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") + clearAllowedHosts, _ := cmd.Flags().GetBool("clear-allowed-hosts") svc := client.Browsers b := BrowsersCmd{browsers: &svc} return b.Update(cmd.Context(), BrowsersUpdateInput{ - Identifier: args[0], - ProxyID: proxyID, - ProxyName: proxyName, - ProxyMode: proxyMode, - ClearProxy: clearProxy, - DisableDefaultProxy: BoolFlag{Set: cmd.Flags().Changed("disable-default-proxy"), Value: disableDefaultProxy}, - ProfileID: profileID, - ProfileName: profileName, - ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, - Viewport: viewport, - Force: force, - Telemetry: telemetry, - TelemetryCdpExclude: telemetryCdpExclude, - Name: name, - SetName: cmd.Flags().Changed("name"), - ClearName: clearName, - Tags: tags, - TagsProvided: tagsProvided, - ClearTags: clearTags, - StartURL: startURL, - Output: out, + Identifier: args[0], + ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, + ClearProxy: clearProxy, + DisableDefaultProxy: BoolFlag{Set: cmd.Flags().Changed("disable-default-proxy"), Value: disableDefaultProxy}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + Viewport: viewport, + Force: force, + Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, + Name: name, + SetName: cmd.Flags().Changed("name"), + ClearName: clearName, + Tags: tags, + TagsProvided: tagsProvided, + ClearTags: clearTags, + StartURL: startURL, + AllowedHosts: allowedHosts, + AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), + ClearAllowedHosts: clearAllowedHosts, + Output: out, }) } diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 5fb8cf07..f3db46e2 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -3090,3 +3090,100 @@ func TestBrowsersRepl_ReportsFailureAndTermination(t *testing.T) { assert.Contains(t, out, "truncated") assert.Contains(t, out, "terminated") } + +func TestBrowsersUpdate_WithAllowedHosts_ForwardsParam(t *testing.T) { + setupStdoutCapture(t) + fake, captured := captureUpdateParams(t) + b := BrowsersCmd{browsers: fake} + + err := b.Update(context.Background(), BrowsersUpdateInput{ + Identifier: "session123", + AllowedHosts: []string{" example.com ", "", "*.example.com"}, + AllowedHostsProvided: true, + }) + + assert.NoError(t, err) + assert.Equal(t, []string{"example.com", "*.example.com"}, captured.Network.AllowedHosts) + raw, marshalErr := json.Marshal(*captured) + require.NoError(t, marshalErr) + assert.Contains(t, string(raw), `"network":{"allowed_hosts":["example.com","*.example.com"]}`) +} + +func TestBrowsersUpdate_ClearAllowedHosts_SendsNull(t *testing.T) { + setupStdoutCapture(t) + fake, captured := captureUpdateParams(t) + b := BrowsersCmd{browsers: fake} + + err := b.Update(context.Background(), BrowsersUpdateInput{ + Identifier: "session123", + ClearAllowedHosts: true, + }) + + assert.NoError(t, err) + raw, marshalErr := json.Marshal(*captured) + require.NoError(t, marshalErr) + assert.Contains(t, string(raw), `"network":{"allowed_hosts":null}`) +} + +// An unrelated update must not touch the allowlist. +func TestBrowsersUpdate_OmitAllowedHosts_NotSent(t *testing.T) { + setupStdoutCapture(t) + fake, captured := captureUpdateParams(t) + b := BrowsersCmd{browsers: fake} + + err := b.Update(context.Background(), BrowsersUpdateInput{ + Identifier: "session123", + Name: "new-name", + SetName: true, + }) + + assert.NoError(t, err) + raw, marshalErr := json.Marshal(*captured) + require.NoError(t, marshalErr) + assert.NotContains(t, string(raw), "network") +} + +func TestBrowsersUpdate_AllowedHostsValidation(t *testing.T) { + tooMany := make([]string, maxAllowedHosts+1) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("host%d.example.com", i) + } + tests := []struct { + name string + in BrowsersUpdateInput + wantErr string + }{ + { + name: "both set and clear", + in: BrowsersUpdateInput{Identifier: "s", AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearAllowedHosts: true}, + wantErr: "cannot specify both --allowed-host and --clear-allowed-hosts", + }, + { + name: "only empty entries", + in: BrowsersUpdateInput{Identifier: "s", AllowedHosts: []string{" ", ""}, AllowedHostsProvided: true}, + wantErr: "at least one --allowed-host entry is required", + }, + { + name: "too many entries", + in: BrowsersUpdateInput{Identifier: "s", AllowedHosts: tooMany, AllowedHostsProvided: true}, + wantErr: "too many --allowed-host entries", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupStdoutCapture(t) + called := false + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, idOrName string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + called = true + return &kernel.BrowserUpdateResponse{}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.Update(context.Background(), tt.in) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.False(t, called) + }) + } +} diff --git a/cmd/proxies/check.go b/cmd/proxies/check.go index 7381446d..2d082b0a 100644 --- a/cmd/proxies/check.go +++ b/cmd/proxies/check.go @@ -94,7 +94,7 @@ func getProxyCheckConfigRows(proxy *kernel.ProxyCheckResponse) [][]string { config := &proxy.Config switch proxy.Type { - case kernel.ProxyCheckResponseTypeDatacenter, kernel.ProxyCheckResponseTypeIsp: + case kernel.ProxyCheckResponseType(proxyTypeDatacenter), kernel.ProxyCheckResponseTypeIsp: if config.Country != "" { rows = append(rows, []string{"Country", config.Country}) } diff --git a/cmd/proxies/check_test.go b/cmd/proxies/check_test.go index 82de52f4..cf1437e4 100644 --- a/cmd/proxies/check_test.go +++ b/cmd/proxies/check_test.go @@ -17,7 +17,7 @@ func TestProxyCheck_ShowsBypassHosts(t *testing.T) { return &kernel.ProxyCheckResponse{ ID: id, Name: "Proxy 1", - Type: kernel.ProxyCheckResponseTypeDatacenter, + Type: kernel.ProxyCheckResponseType(proxyTypeDatacenter), BypassHosts: []string{"localhost", "internal.service.local"}, Status: kernel.ProxyCheckResponseStatusAvailable, }, nil @@ -45,7 +45,7 @@ func TestProxyCheck_PassesURL(t *testing.T) { return &kernel.ProxyCheckResponse{ ID: id, Name: "Proxy 1", - Type: kernel.ProxyCheckResponseTypeDatacenter, + Type: kernel.ProxyCheckResponseType(proxyTypeDatacenter), Status: kernel.ProxyCheckResponseStatusAvailable, }, nil }, diff --git a/cmd/proxies/common_test.go b/cmd/proxies/common_test.go index a7796e24..3165e37d 100644 --- a/cmd/proxies/common_test.go +++ b/cmd/proxies/common_test.go @@ -57,21 +57,21 @@ func (f *FakeProxyService) Get(ctx context.Context, id string, opts ...option.Re if f.GetFunc != nil { return f.GetFunc(ctx, id, opts...) } - return &kernel.ProxyGetResponse{ID: id, Type: kernel.ProxyGetResponseTypeDatacenter}, nil + return &kernel.ProxyGetResponse{ID: id, Type: kernel.ProxyGetResponseType(proxyTypeDatacenter)}, nil } func (f *FakeProxyService) New(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { if f.NewFunc != nil { return f.NewFunc(ctx, body, opts...) } - return &kernel.ProxyNewResponse{ID: "new-proxy", Type: kernel.ProxyNewResponseTypeDatacenter}, nil + return &kernel.ProxyNewResponse{ID: "new-proxy", Type: kernel.ProxyNewResponseType(proxyTypeDatacenter)}, nil } func (f *FakeProxyService) Update(ctx context.Context, id string, body kernel.ProxyUpdateParams, opts ...option.RequestOption) (*kernel.ProxyUpdateResponse, error) { if f.UpdateFunc != nil { return f.UpdateFunc(ctx, id, body, opts...) } - return &kernel.ProxyUpdateResponse{ID: id, Name: body.Name, Type: kernel.ProxyUpdateResponseTypeDatacenter}, nil + return &kernel.ProxyUpdateResponse{ID: id, Name: body.Name, Type: kernel.ProxyUpdateResponseType(proxyTypeDatacenter)}, nil } func (f *FakeProxyService) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { @@ -85,7 +85,7 @@ func (f *FakeProxyService) Check(ctx context.Context, id string, body kernel.Pro if f.CheckFunc != nil { return f.CheckFunc(ctx, id, body, opts...) } - return &kernel.ProxyCheckResponse{ID: id, Type: kernel.ProxyCheckResponseTypeDatacenter}, nil + return &kernel.ProxyCheckResponse{ID: id, Type: kernel.ProxyCheckResponseType(proxyTypeDatacenter)}, nil } // Helper function to create test proxy responses @@ -93,7 +93,7 @@ func createDatacenterProxy(id, name, country string) kernel.ProxyListResponse { return kernel.ProxyListResponse{ ID: id, Name: name, - Type: kernel.ProxyListResponseTypeDatacenter, + Type: kernel.ProxyListResponseType(proxyTypeDatacenter), Config: kernel.ProxyListResponseConfigUnion{ Country: country, }, diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index 68e46757..e4dfa6ef 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -21,8 +21,11 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { // Validate proxy type var proxyType kernel.ProxyNewParamsType switch in.Type { - case "datacenter": - proxyType = kernel.ProxyNewParamsTypeDatacenter + case proxyTypeDatacenter: + // Deprecated: the API still accepts datacenter proxies for backward + // compatibility, but the SDK no longer exposes the type. + pterm.Warning.Println("datacenter proxies are deprecated; use --type isp instead") + proxyType = kernel.ProxyNewParamsType(proxyTypeDatacenter) case "isp": proxyType = kernel.ProxyNewParamsTypeIsp case "residential": @@ -48,16 +51,8 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { // Build config based on type switch proxyType { - case kernel.ProxyNewParamsTypeDatacenter: - config := kernel.ProxyNewParamsConfigDatacenter{} - if in.Country != "" { - config.Country = kernel.Opt(in.Country) - } - params.Config = kernel.ProxyNewParamsConfigUnion{ - OfDatacenter: &config, - } - - case kernel.ProxyNewParamsTypeIsp: + // Datacenter config has the same shape as ISP config (country only). + case kernel.ProxyNewParamsType(proxyTypeDatacenter), kernel.ProxyNewParamsTypeIsp: config := kernel.ProxyNewParamsConfigIsp{} if in.Country != "" { config.Country = kernel.Opt(in.Country) diff --git a/cmd/proxies/create_test.go b/cmd/proxies/create_test.go index 25950519..d37d8ff3 100644 --- a/cmd/proxies/create_test.go +++ b/cmd/proxies/create_test.go @@ -19,19 +19,19 @@ func TestProxyCreate_Datacenter_Success(t *testing.T) { fake := &FakeProxyService{ NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { // Verify the request - assert.Equal(t, kernel.ProxyNewParamsTypeDatacenter, body.Type) + assert.Equal(t, kernel.ProxyNewParamsType(proxyTypeDatacenter), body.Type) assert.Equal(t, "My DC Proxy", body.Name.Value) assert.Equal(t, []string{"localhost", "internal.service.local"}, body.BypassHosts) // Check config - dcConfig := body.Config.OfDatacenter + dcConfig := body.Config.OfIsp assert.NotNil(t, dcConfig) assert.Equal(t, "US", dcConfig.Country.Value) return &kernel.ProxyNewResponse{ ID: "dc-new", Name: "My DC Proxy", - Type: kernel.ProxyNewResponseTypeDatacenter, + Type: kernel.ProxyNewResponseType(proxyTypeDatacenter), BypassHosts: []string{"localhost", "internal.service.local"}, }, nil }, @@ -48,6 +48,7 @@ func TestProxyCreate_Datacenter_Success(t *testing.T) { assert.NoError(t, err) output := buf.String() + assert.Contains(t, output, "datacenter proxies are deprecated") assert.Contains(t, output, "Creating datacenter proxy") assert.Contains(t, output, "Successfully created proxy") assert.Contains(t, output, "dc-new") @@ -63,17 +64,17 @@ func TestProxyCreate_Datacenter_WithoutCountry(t *testing.T) { fake := &FakeProxyService{ NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { // Verify the request - assert.Equal(t, kernel.ProxyNewParamsTypeDatacenter, body.Type) + assert.Equal(t, kernel.ProxyNewParamsType(proxyTypeDatacenter), body.Type) assert.Equal(t, "My DC Proxy", body.Name.Value) // Check config - country should not be set (it should be zero/nil) - dcConfig := body.Config.OfDatacenter + dcConfig := body.Config.OfIsp assert.NotNil(t, dcConfig) return &kernel.ProxyNewResponse{ ID: "dc-new", Name: "My DC Proxy", - Type: kernel.ProxyNewResponseTypeDatacenter, + Type: kernel.ProxyNewResponseType(proxyTypeDatacenter), }, nil }, } @@ -87,6 +88,7 @@ func TestProxyCreate_Datacenter_WithoutCountry(t *testing.T) { assert.NoError(t, err) output := buf.String() + assert.Contains(t, output, "datacenter proxies are deprecated") assert.Contains(t, output, "Creating datacenter proxy") assert.Contains(t, output, "Successfully created proxy") } @@ -163,7 +165,7 @@ func TestProxyCreate_Residential_InvalidOS(t *testing.T) { func TestProxyCreate_MissingName(t *testing.T) { p := ProxyCmd{proxies: &FakeProxyService{}} err := p.Create(context.Background(), ProxyCreateInput{ - Type: "datacenter", + Type: "isp", Country: "US", }) @@ -356,7 +358,7 @@ func TestProxyCreate_Protocol_Valid(t *testing.T) { return &kernel.ProxyNewResponse{ ID: "test-proxy", Name: "Test Proxy", - Type: kernel.ProxyNewResponseTypeDatacenter, + Type: kernel.ProxyNewResponseType(proxyTypeDatacenter), }, nil }, } @@ -364,7 +366,7 @@ func TestProxyCreate_Protocol_Valid(t *testing.T) { p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ Name: "Test Proxy", - Type: "datacenter", + Type: "isp", Country: "US", Protocol: tt.protocol, }) @@ -379,7 +381,7 @@ func TestProxyCreate_Protocol_Invalid(t *testing.T) { p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ Name: "Test Proxy", - Type: "datacenter", + Type: "isp", Country: "US", Protocol: "ftp", }) @@ -394,7 +396,7 @@ func TestProxyCreate_BypassHosts_Normalized(t *testing.T) { assert.Equal(t, []string{"localhost", "internal.service.local"}, body.BypassHosts) return &kernel.ProxyNewResponse{ ID: "test-proxy", - Type: kernel.ProxyNewResponseTypeDatacenter, + Type: kernel.ProxyNewResponseType(proxyTypeDatacenter), }, nil }, } @@ -402,7 +404,7 @@ func TestProxyCreate_BypassHosts_Normalized(t *testing.T) { p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ Name: "Test Proxy", - Type: "datacenter", + Type: "isp", Country: "US", BypassHosts: []string{" localhost ", "", "internal.service.local"}, }) @@ -422,7 +424,7 @@ func TestProxyCreate_APIError(t *testing.T) { p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ Name: "Test", - Type: "datacenter", + Type: "isp", Country: "US", }) diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index e5a7aa8f..7ce6ef86 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -72,7 +72,7 @@ func getProxyConfigRows(proxy *kernel.ProxyGetResponse) [][]string { config := &proxy.Config switch proxy.Type { - case kernel.ProxyGetResponseTypeDatacenter, kernel.ProxyGetResponseTypeIsp: + case kernel.ProxyGetResponseType(proxyTypeDatacenter), kernel.ProxyGetResponseTypeIsp: if config.Country != "" { rows = append(rows, []string{"Country", config.Country}) } diff --git a/cmd/proxies/get_test.go b/cmd/proxies/get_test.go index 55af3014..00df227d 100644 --- a/cmd/proxies/get_test.go +++ b/cmd/proxies/get_test.go @@ -19,7 +19,7 @@ func TestProxyGet_Datacenter(t *testing.T) { return &kernel.ProxyGetResponse{ ID: "dc-1", Name: "US Datacenter", - Type: kernel.ProxyGetResponseTypeDatacenter, + Type: kernel.ProxyGetResponseType(proxyTypeDatacenter), BypassHosts: []string{"localhost", "internal.service.local"}, Config: kernel.ProxyGetResponseConfigUnion{ Country: "US", diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index 1efb5f34..dd4ef5d0 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -109,7 +109,7 @@ func (p ProxyCmd) List(ctx context.Context, in ProxyListInput) error { func formatProxyConfig(proxy *kernel.ProxyListResponse) string { config := &proxy.Config switch proxy.Type { - case kernel.ProxyListResponseTypeDatacenter, kernel.ProxyListResponseTypeIsp: + case kernel.ProxyListResponseType(proxyTypeDatacenter), kernel.ProxyListResponseTypeIsp: if config.Country != "" { return fmt.Sprintf("Country: %s", config.Country) } diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 12827f70..35245985 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -38,17 +38,13 @@ Proxy types (from best to worst for bot detection): - mobile: Mobile carrier proxies - residential: Residential IP proxies - isp: ISP proxies (supported countries: US, GB, FR, DE, SG) -- datacenter: Datacenter proxies - custom: Your own proxy server Country targeting: -- datacenter and isp default to US when --country is omitted +- isp defaults 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" - # Create a custom proxy kernel proxies create --type custom --host proxy.example.com --port 8080 --username myuser --password mypass --name "My Custom Proxy" @@ -62,7 +58,7 @@ Examples: kernel proxies create --type residential --country US --city sanfrancisco --state CA --name "SF Residential" # Create a proxy with bypass hosts - kernel proxies create --type datacenter --country US --bypass-host localhost,internal.service.local --name "Internal Services"`, + kernel proxies create --type isp --country US --bypass-host localhost,internal.service.local --name "Internal Services"`, RunE: runProxiesCreate, } @@ -110,12 +106,12 @@ func init() { // Add flags for create command proxiesCreateCmd.Flags().String("name", "", "Proxy configuration name (required)") _ = proxiesCreateCmd.MarkFlagRequired("name") - proxiesCreateCmd.Flags().String("type", "", "Proxy type (datacenter|isp|residential|mobile|custom)") + proxiesCreateCmd.Flags().String("type", "", "Proxy type (isp|residential|mobile|custom)") _ = proxiesCreateCmd.MarkFlagRequired("type") 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; datacenter and isp default to US, residential and mobile use the global pool without country targeting)") + // Location flags (isp, residential, mobile) + proxiesCreateCmd.Flags().String("country", "", "ISO 3166 country code or EU (isp proxies support US, GB, FR, DE, SG; isp defaults 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/cmd/proxies/types.go b/cmd/proxies/types.go index ee0b6247..232ba0ae 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -9,6 +9,10 @@ import ( "github.com/kernel/kernel-go-sdk/packages/pagination" ) +// proxyTypeDatacenter is the deprecated datacenter proxy type. The SDK no longer +// exposes it, but the API still accepts it and returns it for existing proxies. +const proxyTypeDatacenter = "datacenter" + // ProxyService defines the subset of the Kernel SDK proxy client that we use. type ProxyService interface { List(ctx context.Context, query kernel.ProxyListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.ProxyListResponse], err error) @@ -45,7 +49,7 @@ type ProxyCreateInput struct { Protocol string // Hostnames that should bypass the parent proxy and connect directly. BypassHosts []string - // Datacenter/ISP config + // ISP (and deprecated datacenter) config Country string // Residential/Mobile config City string diff --git a/cmd/proxies/update_test.go b/cmd/proxies/update_test.go index d25d6994..65d7f0ba 100644 --- a/cmd/proxies/update_test.go +++ b/cmd/proxies/update_test.go @@ -22,7 +22,7 @@ func TestProxyUpdate_RenamesProxy(t *testing.T) { return &kernel.ProxyUpdateResponse{ ID: id, Name: body.Name, - Type: kernel.ProxyUpdateResponseTypeDatacenter, + Type: kernel.ProxyUpdateResponseType(proxyTypeDatacenter), Status: kernel.ProxyUpdateResponseStatusAvailable, }, nil }, diff --git a/go.mod b/go.mod index fd049025..5c796ea9 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.120.0 + github.com/kernel/kernel-go-sdk v0.120.1-0.20261007192648-b71ecfcbaeec 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 27c84c37..12cc7ac5 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.120.0 h1:m4E5OPcv3jZfODi+zP5waICXL1fvcoBD0xdvDAAP4hM= -github.com/kernel/kernel-go-sdk v0.120.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.120.1-0.20261007192648-b71ecfcbaeec h1:VYuPfOdrbx+Kt06CZQQ9maA1Oxr0BESMoQE0TH+Fu3U= +github.com/kernel/kernel-go-sdk v0.120.1-0.20261007192648-b71ecfcbaeec/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 f15fe55fb6273ed8f855b8734157aa1ad5a10586 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:00:52 +0000 Subject: [PATCH 19/20] CLI: Update Go SDK to aa4c1318242d and drop browser_id from 1Password access requests The SDK removed BrowserID from 1pw_create_access_request and 1pw_access_request_status (requests now go over the 1Password API; no browser needed). Remove browser_id from the accepted params for both operations, make --params optional for them, and update help text. 1pw_fill still requires browser_id. Tested: go test ./...; vaults items invoke ... 1pw_create_access_request (no params) and 1pw_access_request_status --params '{"timeout_seconds":5}' reach the API (404 on nonexistent vault); browser_id is now rejected for access requests; 1pw_fill still requires --params. Co-Authored-By: Claude Opus 5.5 --- cmd/vaults_commands.go | 14 +++++++------- cmd/vaults_credentials.go | 2 +- cmd/vaults_onepassword_test.go | 23 +++++++++++++---------- cmd/vaults_operation_params.go | 22 +++++++++++++--------- go.mod | 2 +- go.sum | 4 ++-- 6 files changed, 37 insertions(+), 30 deletions(-) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 0402b6a1..4f30f522 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -214,11 +214,11 @@ exit nonzero with JSON retained on stdout. unknown means the tool may have run: and never retry automatically. 400/403/404/409 rejections mean the tool was not invoked. 1Password credentials (see credentials --help) use --params or --spec-file without type: -1pw_create_access_request: browser_id (vault-bound session ID); optional goal (<=140), - reason (<=100), keywords (1-5 strings); reason and keywords only for single-entry - credentials. Present the returned onepassword:// approval link and instructions to - the account owner unchanged; invoke it once per credential. -1pw_access_request_status: browser_id; optional timeout_seconds 0-120 (default 10). +1pw_create_access_request: no browser needed; optional goal (<=140), reason (<=100), + keywords (1-5 strings); reason and keywords only for single-entry credentials. + Present the returned onepassword:// approval link and instructions to the account + owner unchanged; invoke it once per credential. +1pw_access_request_status: optional timeout_seconds 0-120 (default 10). Check status after the account owner has the approval link. 1pw_fill: browser_id and the exact page_url of one open login page on a requested origin; entry_id when several approved entries share that origin; optional @@ -239,8 +239,8 @@ JSON kernel vaults items invoke user-vault login webmcp_invoke --spec-file - <<'JSON' {"browser_id":"","tool_ref":"","page_url":"https://example.com/login","input":{"email":null,"password":null},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}]} JSON - kernel vaults items invoke user-vault github 1pw_create_access_request --params '{"browser_id":"","reason":"Sign in to GitHub"}' - kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"browser_id":"","timeout_seconds":60}' + kernel vaults items invoke user-vault github 1pw_create_access_request --params '{"reason":"Sign in to GitHub"}' + kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"timeout_seconds":60}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login","entry_id":""}' 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"}]}' -o json`, diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 1ffeaade..98db4147 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -53,7 +53,7 @@ const vaultOnePasswordCredentialHelp = `1Password flow: with the HTTPS website of a login page. Use one entry unless the user needs several logins, such as separate accounts or sign-in origins; per-entry reason and keywords go in the spec. No field definitions, selectors, or values are accepted. -3. Invoke 1pw_create_access_request once with a vault-bound browser_id. Present the +3. Invoke 1pw_create_access_request once (no browser needed). Present the returned onepassword:// approval link and instructions to the account owner unchanged. 4. Invoke 1pw_access_request_status (timeout_seconds 0-120) until the credential is ready, declined, or failed. Ready means approved, not signed in. diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index e6f3aef7..a6ade2b8 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -134,11 +134,13 @@ func TestOnePasswordOperationRequests(t *testing.T) { for _, tc := range []struct { operation, params, body, item string }{ - {"1pw_create_access_request", `{"browser_id":"browser-1","goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, `{"type":"1pw_create_access_request","browser_id":"browser-1","goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, onePasswordCredentialFixture}, - {"1pw_access_request_status", `{"browser_id":"browser-1","timeout_seconds":60}`, `{"type":"1pw_access_request_status","browser_id":"browser-1","timeout_seconds":60}`, onePasswordCredentialFixture}, + {"1pw_create_access_request", `{"goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, `{"type":"1pw_create_access_request","goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, onePasswordCredentialFixture}, + {"1pw_create_access_request", "", `{"type":"1pw_create_access_request"}`, onePasswordCredentialFixture}, + {"1pw_access_request_status", `{"timeout_seconds":60}`, `{"type":"1pw_access_request_status","timeout_seconds":60}`, onePasswordCredentialFixture}, + {"1pw_access_request_status", "", `{"type":"1pw_access_request_status"}`, onePasswordCredentialFixture}, {"1pw_recover", "", `{"type":"1pw_recover"}`, onePasswordAccountFixture}, } { - t.Run(tc.operation, func(t *testing.T) { + t.Run(tc.operation+tc.params, func(t *testing.T) { item := strings.Replace(tc.item, `"available_operations":[]`, `"available_operations":[{"type":"1pw_access_request_status","description":"x"}]`, 1) item = strings.Replace(item, `"1pw_access_request_status"`, `"`+tc.operation+`"`, 1) posts := 0 @@ -166,11 +168,12 @@ func TestOnePasswordOperationRequests(t *testing.T) { func TestOnePasswordOperationValidation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") for _, tc := range []struct{ operation, params, err string }{ - {"1pw_create_access_request", "", "requires --params"}, - {"1pw_create_access_request", `{"browser_id":""}`, "browser_id"}, - {"1pw_create_access_request", `{"browser_id":"b","password":"x"}`, "only supported"}, - {"1pw_create_access_request", `{"type":"1pw_create_access_request","browser_id":"b"}`, "must not contain type"}, - {"1pw_access_request_status", `{"browser_id":"b","timeout_seconds":121}`, "timeout_seconds"}, + {"1pw_create_access_request", `{"browser_id":"b"}`, "only supported"}, + {"1pw_create_access_request", `{"password":"x"}`, "only supported"}, + {"1pw_create_access_request", `{"type":"1pw_create_access_request"}`, "must not contain type"}, + {"1pw_access_request_status", `{"timeout_seconds":121}`, "timeout_seconds"}, + {"1pw_fill", "", "requires --params"}, + {"1pw_fill", `{"browser_id":"","page_url":"https://github.com/login"}`, "browser_id"}, {"1pw_reconcile_access", `{"acknowledge_unconfirmed":true}`, "unsupported 1Password operation"}, {"1pw_fill", `{"browser_id":"b"}`, "page_url"}, {"1pw_fill", `{"browser_id":"b","page_url":"https://github.com/login","timeout_ms":0}`, "timeout_ms"}, @@ -400,7 +403,7 @@ func TestOnePasswordOperationErrorGuidance(t *testing.T) { w.WriteHeader(tc.status) io.WriteString(w, tc.body) }) - _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "github", "1pw_create_access_request", "--params", `{"browser_id":"browser-1"}`) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "github", "1pw_create_access_request") require.ErrorContains(t, err, tc.want) assert.NotContains(t, err.Error(), "secret-echo") assert.NotContains(t, err.Error(), "before retrying") @@ -412,7 +415,7 @@ func TestOnePasswordOperationErrorGuidance(t *testing.T) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, onePasswordCredentialFixture) }) - _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "github", "1pw_create_access_request", "--params", `{"browser_id":"browser-1"}`) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "github", "1pw_create_access_request") require.ErrorContains(t, err, "check that the linked credential_account is connected") assert.Contains(t, err.Error(), "do not delete or recreate the item") } diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 5a0d5cd2..edf4d189 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -99,7 +99,11 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( return &vaultOperationParams{OnePassword: &kernel.VaultItemPerformOperationParams{Of1pwRecover: &kernel.OnePasswordRecoverVaultItemOperationRequestParam{Type: kernel.OnePasswordRecoverVaultItemOperationRequestType1pwRecover}}}, nil } if !paramsSet { - return nil, fmt.Errorf("%s requires --params or --spec-file", operation) + // Access request operations have only optional parameters. + if operation != "1pw_create_access_request" && operation != "1pw_access_request_status" { + return nil, fmt.Errorf("%s requires --params or --spec-file", operation) + } + raw = "{}" } request, err := parseOnePasswordOperationParams(operation, raw) if err != nil { @@ -195,8 +199,8 @@ func parseVaultFillParams(raw string) (*vaultFillParams, error) { func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPerformOperationParams, error) { allowed := map[string]string{ - "1pw_create_access_request": "browser_id goal reason keywords", - "1pw_access_request_status": "browser_id timeout_seconds", + "1pw_create_access_request": "goal reason keywords", + "1pw_access_request_status": "timeout_seconds", "1pw_fill": "browser_id page_url entry_id timeout_ms", "1pw_update_access_token": "access_token", }[operation] @@ -214,13 +218,9 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe } return &kernel.VaultItemPerformOperationParams{Of1pwUpdateAccessToken: &request}, nil } - var browserID string - if json.Unmarshal(object["browser_id"], &browserID) != nil || strings.TrimSpace(browserID) == "" { - return nil, fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") - } switch operation { case "1pw_create_access_request": - request := kernel.OnePasswordRequestAccessVaultItemOperationRequestParam{BrowserID: browserID, Type: kernel.OnePasswordRequestAccessVaultItemOperationRequestType1pwCreateAccessRequest} + request := kernel.OnePasswordRequestAccessVaultItemOperationRequestParam{Type: kernel.OnePasswordRequestAccessVaultItemOperationRequestType1pwCreateAccessRequest} for _, name := range []string{"goal", "reason"} { if value, ok := object[name]; ok { var text string @@ -239,7 +239,7 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe } return &kernel.VaultItemPerformOperationParams{Of1pwCreateAccessRequest: &request}, nil case "1pw_access_request_status": - request := kernel.VaultItemPerformOperationParamsBody1pwAccessRequestStatus{BrowserID: browserID} + request := kernel.VaultItemPerformOperationParamsBody1pwAccessRequestStatus{} if value, ok := object["timeout_seconds"]; ok { var timeout *int64 if json.Unmarshal(value, &timeout) != nil || timeout == nil || *timeout < 0 || *timeout > 120 { @@ -249,6 +249,10 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe } return &kernel.VaultItemPerformOperationParams{Of1pwAccessRequestStatus: &request}, nil default: + var browserID string + if json.Unmarshal(object["browser_id"], &browserID) != nil || strings.TrimSpace(browserID) == "" { + return nil, fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") + } request := kernel.OnePasswordFillVaultItemOperationRequestParam{BrowserID: browserID, Type: kernel.OnePasswordFillVaultItemOperationRequestType1pwFill} if json.Unmarshal(object["page_url"], &request.PageURL) != nil { return nil, fmt.Errorf("page_url must be the exact absolute URL of the open login page") diff --git a/go.mod b/go.mod index 5c796ea9..109a11f5 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.120.1-0.20261007192648-b71ecfcbaeec + github.com/kernel/kernel-go-sdk v0.120.1-0.20261007195342-aa4c1318242d 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 12cc7ac5..4386695c 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.120.1-0.20261007192648-b71ecfcbaeec h1:VYuPfOdrbx+Kt06CZQQ9maA1Oxr0BESMoQE0TH+Fu3U= -github.com/kernel/kernel-go-sdk v0.120.1-0.20261007192648-b71ecfcbaeec/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.120.1-0.20261007195342-aa4c1318242d h1:LDSpjqTKJpgVe6d0TwBnsiIwQaANlyAbHodomZkNnb8= +github.com/kernel/kernel-go-sdk v0.120.1-0.20261007195342-aa4c1318242d/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 1e339c067d38e892cabc63dff78a83447093b237 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:03:02 +0000 Subject: [PATCH 20/20] CLI: Update Go SDK to 05978d27d142b72683387eb85112daf64a3246e4 (v0.121.0) SDK diff since aa4c1318242d contains only the version bump; full enumeration of api.md methods/params found no CLI coverage gaps. Tested: go build ./..., go test ./... Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 109a11f5..3edad86a 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.120.1-0.20261007195342-aa4c1318242d + github.com/kernel/kernel-go-sdk v0.121.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 4386695c..2565ff93 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.120.1-0.20261007195342-aa4c1318242d h1:LDSpjqTKJpgVe6d0TwBnsiIwQaANlyAbHodomZkNnb8= -github.com/kernel/kernel-go-sdk v0.120.1-0.20261007195342-aa4c1318242d/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.121.0 h1:fuw8MVy4XN71MtBc/bY+b2Oeuy40VVnEHzk+9Kf9fcw= +github.com/kernel/kernel-go-sdk v0.121.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=