From 8d09bc5305769b09222918441fe1845a34b70467 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:54:30 +0000 Subject: [PATCH 1/8] Update Go SDK to 47914f683b2ee9414fba387299f0894e67b2ae2c and CLI coverage --- cmd/api_keys.go | 13 ++++++++++++- cmd/api_keys_test.go | 17 ++++++++++++++--- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 33a3344a..e4bae873 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -188,7 +188,7 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { // Only surface Deleted At when the filter can actually return deleted keys. showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all" - header := []string{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Created At"} + header := []string{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Last Used At", "Created At"} if showDeletedAt { header = append(header, "Deleted At") } @@ -201,6 +201,7 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { formatAPIKeyProject(key), key.MaskedKey, formatAPIKeyExpiresAt(key), + formatAPIKeyLastUsedAt(key), util.FormatLocal(key.CreatedAt), } if showDeletedAt { @@ -356,6 +357,7 @@ func renderAPIKeyDetails(key *kernel.APIKey) { {"Masked Key", key.MaskedKey}, {"Created By", formatAPIKeyCreator(*key)}, {"Expires At", formatAPIKeyExpiresAt(*key)}, + {"Last Used At", formatAPIKeyLastUsedAt(*key)}, {"Created At", util.FormatLocal(key.CreatedAt)}, } PrintTableNoPad(rows, true) @@ -395,6 +397,13 @@ func formatAPIKeyExpiresAt(key kernel.APIKey) string { return util.FormatLocal(key.ExpiresAt) } +func formatAPIKeyLastUsedAt(key kernel.APIKey) string { + if !key.JSON.LastUsedAt.Valid() { + return "-" + } + return util.FormatLocal(key.LastUsedAt) +} + func getAPIKeysHandler(cmd *cobra.Command) APIKeysCmd { client := getKernelClient(cmd) return APIKeysCmd{apiKeys: &client.APIKeys} @@ -497,6 +506,7 @@ var apiKeysCreateCmd = &cobra.Command{ var apiKeysListCmd = &cobra.Command{ Use: "list", Short: "List API keys", + Long: "List API keys. Last-used timestamps are accurate to within about 20 minutes; '-' means no use has been recorded since tracking began.", Args: cobra.NoArgs, RunE: runAPIKeysList, } @@ -504,6 +514,7 @@ var apiKeysListCmd = &cobra.Command{ var apiKeysGetCmd = &cobra.Command{ Use: "get ", Short: "Get an API key", + Long: "Get an API key. The last-used timestamp is accurate to within about 20 minutes; '-' means no use has been recorded since tracking began.", Args: cobra.ExactArgs(1), RunE: runAPIKeysGet, } diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 23d12167..9f1fb35d 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" @@ -183,7 +184,7 @@ func TestAPIKeysRejectInvalidOutputBeforeCallingAPI(t *testing.T) { func TestAPIKeysListPassesPaginationAndRendersRows(t *testing.T) { buf := capturePtermOutput(t) - key := *apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`) + key := *apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"last_used_at":"2026-05-28T13:30:00Z","project_id":null,"project_name":null}`) fake := &FakeAPIKeysService{ ListFunc: func(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) { assert.True(t, query.Limit.Valid()) @@ -202,6 +203,13 @@ func TestAPIKeysListPassesPaginationAndRendersRows(t *testing.T) { assert.Contains(t, out, "key_123") assert.Contains(t, out, "ci") assert.Contains(t, out, "Never") + assert.Contains(t, out, "Last Used At") + assert.Contains(t, out, util.FormatLocal(key.LastUsedAt)) +} + +func TestFormatAPIKeyLastUsedAtHandlesUnrecordedUse(t *testing.T) { + key := apiKeyFromJSON(`{"last_used_at":null}`) + assert.Equal(t, "-", formatAPIKeyLastUsedAt(*key)) } func TestAPIKeysUpdateRequiresName(t *testing.T) { @@ -338,12 +346,13 @@ func TestAPIKeysRotateOmitsUnsetParams(t *testing.T) { } func TestAPIKeysGetPassesIncludeDeleted(t *testing.T) { - capturePtermOutput(t) + buf := capturePtermOutput(t) + key := apiKeyFromJSON(`{"id":"key_123","name":"default","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"last_used_at":"2026-05-28T13:30:00Z","project_id":null,"project_name":null}`) var captured kernel.APIKeyGetParams fake := &FakeAPIKeysService{ GetFunc: func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) { captured = query - return apiKeyFromJSON(`{"id":"` + id + `","name":"default","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil + return key, nil }, } c := APIKeysCmd{apiKeys: fake} @@ -351,4 +360,6 @@ func TestAPIKeysGetPassesIncludeDeleted(t *testing.T) { require.NoError(t, c.Get(context.Background(), APIKeysGetInput{ID: "key_123", IncludeDeleted: true})) assert.True(t, captured.IncludeDeleted.Valid()) assert.True(t, captured.IncludeDeleted.Value) + assert.Contains(t, buf.String(), "Last Used At") + assert.Contains(t, buf.String(), util.FormatLocal(key.LastUsedAt)) } diff --git a/go.mod b/go.mod index 73f72506..50e81ce4 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.131.0 + github.com/kernel/kernel-go-sdk v0.132.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 2b7257a1..411ca31d 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.131.0 h1:9aYi3O5N8g/cNwAIevmL+lOy8yy6daz5EXciCPipLco= -github.com/kernel/kernel-go-sdk v0.131.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.132.0 h1:LWwIuUqQ2fYu0oLUE9a1G7Yi0Id1Yb32bVIFBdIqQV0= +github.com/kernel/kernel-go-sdk v0.132.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 829666512b157c2c060716d4f680e5d0e871ca28 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:05:58 +0000 Subject: [PATCH 2/8] Update Go SDK to 754943ffe42e7755c64fdf9b383ad4bad39a94f2 and CLI coverage --- cmd/browsers.go | 101 ++++++++++++ cmd/browsers_test.go | 61 ++++++++ cmd/vault_provider_configs.go | 6 +- cmd/vaults.go | 67 ++++++-- cmd/vaults_commands.go | 136 +++++++++++----- cmd/vaults_credentials.go | 21 +-- cmd/vaults_fill.go | 7 +- cmd/vaults_help.go | 87 +++++++++-- cmd/vaults_kernel_errors.go | 33 +++- cmd/vaults_kernel_payment_test.go | 21 +++ cmd/vaults_kernel_recovery_test.go | 11 +- cmd/vaults_link.go | 14 +- cmd/vaults_no_replay_test.go | 55 +++++++ cmd/vaults_onepassword_resolved_test.go | 37 +++++ cmd/vaults_onepassword_test.go | 13 +- cmd/vaults_operation_params.go | 62 +++++++- cmd/vaults_output.go | 131 ++++++++++++---- cmd/vaults_payment_method_operations_test.go | 154 +++++++++++++++++++ cmd/vaults_payment_request_test.go | 138 +++++++++++++++++ cmd/vaults_webmcp.go | 5 +- go.mod | 3 +- go.sum | 6 +- 22 files changed, 1033 insertions(+), 136 deletions(-) create mode 100644 cmd/vaults_no_replay_test.go create mode 100644 cmd/vaults_onepassword_resolved_test.go create mode 100644 cmd/vaults_payment_method_operations_test.go create mode 100644 cmd/vaults_payment_request_test.go diff --git a/cmd/browsers.go b/cmd/browsers.go index 8aa800e0..8a2a9d4f 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -42,6 +42,7 @@ type BrowsersService interface { HTTPClient(id string, opts ...option.RequestOption) (*http.Client, error) LoadExtensions(ctx context.Context, idOrName string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error) Repl(ctx context.Context, idOrName string, body kernel.BrowserReplParams, opts ...option.RequestOption) (res *kernel.BrowserReplResult, err error) + SaveProfile(ctx context.Context, idOrName string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (res *kernel.Profile, err error) } // BrowserReplaysService defines the subset we use for browser replays. @@ -561,6 +562,13 @@ type BrowsersGetInput struct { Output string } +type BrowsersSaveProfileInput struct { + Identifier string + ProfileID string + ProfileName string + Output string +} + type BrowsersUpdateInput struct { Identifier string ProxyID string @@ -1084,6 +1092,44 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { return nil } +func (b BrowsersCmd) SaveProfile(ctx context.Context, in BrowsersSaveProfileInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if (in.ProfileID == "") == (in.ProfileName == "") { + return fmt.Errorf("specify exactly one of --profile-id or --profile-name") + } + + destination := kernel.SaveProfileRequestParam{} + if in.ProfileID != "" { + destination.ID = kernel.Opt(in.ProfileID) + } else { + destination.Name = kernel.Opt(in.ProfileName) + } + profile, err := b.browsers.SaveProfile(ctx, in.Identifier, kernel.BrowserSaveProfileParams{SaveProfileRequest: destination}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(profile) + } + + name := profile.Name + if name == "" { + name = "-" + } + PrintTableNoPad(pterm.TableData{ + {"Property", "Value"}, + {"ID", profile.ID}, + {"Name", name}, + {"Created At", util.FormatLocal(profile.CreatedAt)}, + {"Updated At", util.FormatLocal(profile.UpdatedAt)}, + {"Last Used At", util.FormatLocal(profile.LastUsedAt)}, + }, true) + pterm.Info.Println("The browser restarted. Reconnect CDP clients; WebDriver clients must create a new session.") + return nil +} + // browserUpdateProxySelection folds every proxy flag `browsers update` accepts // into the single proxy configuration the API takes. --clear-proxy drops a // selected proxy back to the browser default, and --disable-default-proxy @@ -3002,6 +3048,38 @@ var browsersGetCmd = &cobra.Command{ RunE: runBrowsersGet, } +var browsersSaveProfileCmd = &cobra.Command{ + Use: "save-profile ", + Short: "Save the running browser state to a profile", + Long: `Save the running browser's current user-data state to an existing or new profile. + +Provide exactly one destination: --profile-id overwrites an existing profile; +--profile-name overwrites a matching profile in the browser's project or creates it. + +Saving restarts the browser without ending the session. Active CDP and WebDriver +connections close; reconnect CDP clients and create a new WebDriver session. Tabs are +restored, but pages reload and in-memory page state is lost. The command returns after +the restarted browser accepts CDP connections and the profile upload completes. Caches, +Service Worker storage, and Google Drive IndexedDB data are excluded from the saved +profile but remain in the running browser. + +This does not attach the destination profile to the browser or change save_changes. +Other active browsers that already loaded the destination keep their in-memory state. +Pools with refresh_on_profile_update flush idle sessions and replenish from this +snapshot; other browsers receive it the next time they load the profile. + +Of overlapping saves made by this command to the same profile, the most recently +started save wins. An automatic end-of-session save from save_changes is instead +ordered against these saves by completion time. A successful save restarts the browser +inactivity timeout; a failed save leaves it unchanged. + +A 409 can mean the browser is inactive, a pooled browser was released or reacquired +(browser_lease_changed), or it is briefly busy (browser_busy). Retry only browser_busy, +or resolve the current pooled-browser lease before making a new explicit save.`, + Args: cobra.ExactArgs(1), + RunE: runBrowsersSaveProfile, +} + var browsersUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update a browser session by ID or name", @@ -3069,6 +3147,13 @@ func init() { // view flags addJSONOutputFlag(browsersViewCmd) + // save-profile flags + addJSONOutputFlag(browsersSaveProfileCmd) + browsersSaveProfileCmd.Flags().String("profile-id", "", "Existing profile ID to overwrite (mutually exclusive with --profile-name)") + browsersSaveProfileCmd.Flags().String("profile-name", "", "Profile name to overwrite or create (mutually exclusive with --profile-id)") + browsersSaveProfileCmd.MarkFlagsOneRequired("profile-id", "profile-name") + browsersSaveProfileCmd.MarkFlagsMutuallyExclusive("profile-id", "profile-name") + // update flags addJSONOutputFlag(browsersUpdateCmd) browsersUpdateCmd.Flags().String("proxy-id", "", "ID of the proxy to use for the browser session (mutually exclusive with --proxy-name and --proxy-mode)") @@ -3100,6 +3185,7 @@ func init() { browsersCmd.AddCommand(browsersDeleteCmd) browsersCmd.AddCommand(browsersViewCmd) browsersCmd.AddCommand(browsersGetCmd) + browsersCmd.AddCommand(browsersSaveProfileCmd) browsersCmd.AddCommand(browsersUpdateCmd) // ssh @@ -3753,6 +3839,21 @@ func runBrowsersGet(cmd *cobra.Command, args []string) error { }) } +func runBrowsersSaveProfile(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + profileID, _ := cmd.Flags().GetString("profile-id") + profileName, _ := cmd.Flags().GetString("profile-name") + + svc := client.Browsers + return (BrowsersCmd{browsers: &svc}).SaveProfile(cmd.Context(), BrowsersSaveProfileInput{ + Identifier: args[0], + ProfileID: profileID, + ProfileName: profileName, + Output: output, + }) +} + func runBrowsersUpdate(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) out, _ := cmd.Flags().GetString("output") diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index c6b4fa64..799a8dd2 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -67,6 +67,7 @@ type FakeBrowsersService struct { HTTPClientFunc func(id string, opts ...option.RequestOption) (*http.Client, error) LoadExtensionsFunc func(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error ReplFunc func(ctx context.Context, id string, body kernel.BrowserReplParams, opts ...option.RequestOption) (*kernel.BrowserReplResult, error) + SaveProfileFunc func(ctx context.Context, id string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (*kernel.Profile, error) } func (f *FakeBrowsersService) Get(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { @@ -125,6 +126,66 @@ func (f *FakeBrowsersService) Repl(ctx context.Context, id string, body kernel.B return &kernel.BrowserReplResult{}, nil } +func (f *FakeBrowsersService) SaveProfile(ctx context.Context, id string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (*kernel.Profile, error) { + if f.SaveProfileFunc != nil { + return f.SaveProfileFunc(ctx, id, body, opts...) + } + return &kernel.Profile{}, nil +} + +func TestBrowsersSaveProfile(t *testing.T) { + created := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + input BrowsersSaveProfileInput + expected string + }{ + {name: "existing id", input: BrowsersSaveProfileInput{Identifier: "browser-1", ProfileID: "profile-1", Output: "json"}, expected: `{"id":"profile-1"}`}, + {name: "new name", input: BrowsersSaveProfileInput{Identifier: "browser-1", ProfileName: "checkout", Output: "json"}, expected: `{"name":"checkout"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + fake := &FakeBrowsersService{SaveProfileFunc: func(ctx context.Context, id string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (*kernel.Profile, error) { + calls++ + assert.Equal(t, "browser-1", id) + raw, err := json.Marshal(body) + require.NoError(t, err) + assert.JSONEq(t, tc.expected, string(raw)) + var profile kernel.Profile + require.NoError(t, json.Unmarshal([]byte(fmt.Sprintf(`{"id":"profile-1","name":"checkout","created_at":%q,"updated_at":%q,"last_used_at":%q}`, created.Format(time.RFC3339), created.Format(time.RFC3339), created.Format(time.RFC3339))), &profile)) + return &profile, nil + }} + out := captureStdout(t, func() { + require.NoError(t, (BrowsersCmd{browsers: fake}).SaveProfile(context.Background(), tc.input)) + }) + assert.Equal(t, 1, calls) + assert.Contains(t, out, `"id": "profile-1"`) + }) + } +} + +func TestBrowsersSaveProfileValidationAndHelp(t *testing.T) { + fake := &FakeBrowsersService{SaveProfileFunc: func(ctx context.Context, id string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (*kernel.Profile, error) { + t.Fatal("invalid destination reached API") + return nil, nil + }} + b := BrowsersCmd{browsers: fake} + for _, input := range []BrowsersSaveProfileInput{ + {Identifier: "browser-1"}, + {Identifier: "browser-1", ProfileID: "profile-1", ProfileName: "checkout"}, + } { + require.ErrorContains(t, b.SaveProfile(context.Background(), input), "exactly one") + } + + cmd, _, err := rootCmd.Find([]string{"browsers", "save-profile"}) + require.NoError(t, err) + assert.NotNil(t, cmd.Flags().Lookup("profile-id")) + assert.NotNil(t, cmd.Flags().Lookup("profile-name")) + for _, text := range []string{"Saving restarts the browser", "CDP", "WebDriver", "pages reload", "profile upload completes", "remain in the running browser", "does not attach", "save_changes", "refresh_on_profile_update", "completion time", "failed save leaves it unchanged", "browser_busy", "browser_lease_changed"} { + assert.Contains(t, cmd.Long, text) + } +} + func TestBrowsersCurlRawUsesBrowserHTTPClient(t *testing.T) { var ( gotMethod string diff --git a/cmd/vault_provider_configs.go b/cmd/vault_provider_configs.go index ca18d59d..2ecd237e 100644 --- a/cmd/vault_provider_configs.go +++ b/cmd/vault_provider_configs.go @@ -79,7 +79,7 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect params.OfAgentcard = &kernel.VaultProviderConfigNewParamsBodyAgentcard{Name: name, Credentials: kernel.VaultProviderConfigNewParamsBodyAgentcardCredentials{ClientID: credentials["client_id"], ClientSecret: credentials["client_secret"]}} } c := getVaultProviderConfigsHandler(cmd) - config, err := c.configs.New(cmd.Context(), params, option.WithMaxRetries(0)) + config, err := c.configs.New(cmd.Context(), params, vaultWriteRequestOptions()...) if err != nil { return vaultCredentialError(err) } @@ -144,7 +144,7 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect params.Credentials.PublishableKey = kernel.Opt(publishableKey) } c := getVaultProviderConfigsHandler(cmd) - config, err := c.configs.Update(cmd.Context(), args[0], params, option.WithMaxRetries(0)) + config, err := c.configs.Update(cmd.Context(), args[0], params, vaultWriteRequestOptions()...) if err != nil { return vaultCredentialError(err) } @@ -170,7 +170,7 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect return nil } } - err := c.configs.Delete(cmd.Context(), args[0], option.WithMaxRetries(0)) + err := c.configs.Delete(cmd.Context(), args[0], vaultWriteRequestOptions()...) if err != nil { return vaultCredentialError(err) } diff --git a/cmd/vaults.go b/cmd/vaults.go index 91495c55..a0b4507a 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -36,7 +36,7 @@ func (c VaultsCmd) Create(ctx context.Context, name, output string) error { if err := validateVaultName(name, "--name"); err != nil { return err } - v, err := c.vaults.Upsert(ctx, kernel.VaultUpsertParams{Name: name}, option.WithMaxRetries(0)) + v, err := c.vaults.Upsert(ctx, kernel.VaultUpsertParams{Name: name}, vaultWriteRequestOptions()...) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -126,9 +126,9 @@ func (c VaultsCmd) Delete(ctx context.Context, vault, key string, yes bool) erro } var err error if key == "" { - err = c.vaults.Delete(ctx, vault, option.WithMaxRetries(0)) + err = c.vaults.Delete(ctx, vault, vaultWriteRequestOptions()...) } else { - err = c.vaults.Items.Delete(ctx, key, kernel.VaultItemDeleteParams{IDOrName: vault}, option.WithMaxRetries(0)) + err = c.vaults.Items.Delete(ctx, key, kernel.VaultItemDeleteParams{IDOrName: vault}, vaultWriteRequestOptions()...) } if err != nil && !util.IsNotFound(err) { return util.CleanedUpSdkError{Err: err} @@ -234,7 +234,7 @@ func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, e } func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec kernel.VaultItemUpsertParamsBodyWalletSpecUnion, output string, open bool) error { - item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfWallet: &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec}}, option.WithMaxRetries(0)) + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfWallet: &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec}}, vaultWriteRequestOptions()...) if err != nil { if vaultSpecProvider(spec) == "kernel" { return kernelVaultRequestError(err, "create") @@ -248,9 +248,9 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. var item *kernel.VaultItemUnion var err error if update { - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{Type: "card", Spec: spec}}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{Type: "card", Spec: spec}}, vaultWriteRequestOptions()...) } else { - item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, vaultWriteRequestOptions()...) } if err != nil { if vaultSpecProvider(spec) == "kernel" { @@ -261,6 +261,36 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. return c.showItem(item, output, false) } +func (c VaultsCmd) SavePaymentRequest(ctx context.Context, vault, key string, spec kernel.PaymentRequestVaultItemSpecUnionParam, update bool, output string) error { + var item *kernel.VaultItemUnion + var err error + if update { + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{ + IDOrName: vault, + OfPaymentRequestVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyPaymentRequestVaultItemUpdateRequest{ + Type: "payment_request", + Spec: spec, + }, + }, vaultWriteRequestOptions()...) + } else { + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{ + IDOrName: vault, + OfPaymentRequest: &kernel.VaultItemUpsertParamsBodyPaymentRequest{Spec: spec}, + }, vaultWriteRequestOptions()...) + } + if err != nil { + if vaultSpecProvider(spec) == "kernel" { + operation := "create" + if update { + operation = "update" + } + return kernelVaultRequestError(err, operation) + } + return util.CleanedUpSdkError{Err: err} + } + return c.showItem(item, output, false) +} + func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultOperationParams, output string, open bool) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") @@ -283,6 +313,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if isOnePasswordOperation(operation) && (params == nil || params.OnePassword == nil) { return fmt.Errorf("%s requires its documented parameters", operation) } + if (operation == "add_payment_method" || operation == "remove_payment_method") && (params == nil || params.PaymentMethod == 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" || operation == "webmcp_invoke" { @@ -325,13 +358,13 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("confirm_transaction currency must match the approved purchase currency and amount must not exceed the approved purchase amount; operation was not invoked") } } - linkCard := item.Type == "card" && item.Spec.Provider == "link" - if params != nil && params.Authorize != nil && !linkCard { - return fmt.Errorf("--browser-id and --page-url only apply to Link cards; authorize was not invoked") + linkPurchase := (item.Type == "card" || item.Type == "payment_request") && item.Spec.Provider == "link" + if params != nil && params.Authorize != nil && !linkPurchase { + return fmt.Errorf("--browser-id and --page-url only apply to Link cards or Link payment_request items; authorize was not invoked") } if operation == "fill" { - if !linkCard && len(params.Fill.Fields) == 0 { - return fmt.Errorf("fill for this item requires 1-32 fields binding field names to selectors; only a Link card's fill may omit them. fill was not invoked") + if !linkPurchase && len(params.Fill.Fields) == 0 { + return fmt.Errorf("fill for this item requires 1-32 fields binding field names to selectors; only a Link card's fill may omit them, including the equivalent Link payment_request fill. fill was not invoked") } return c.fill(ctx, vault, key, params.Fill, output, item.Spec.Provider) } @@ -342,7 +375,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return c.webMCPInvoke(ctx, vault, key, params.WebMCP, output) } request := kernel.VaultItemPerformOperationParams{IDOrName: vault} - opts := []option.RequestOption{option.WithMaxRetries(0)} + opts := vaultWriteRequestOptions() if operation == "confirm_transaction" { // Replace with a typed SDK parameter once the operation is generated. body, err := json.Marshal(params.ConfirmTransaction) @@ -356,6 +389,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } else if params != nil && params.OnePassword != nil { request = *params.OnePassword request.IDOrName = vault + } else if params != nil && params.PaymentMethod != nil { + request = *params.PaymentMethod + request.IDOrName = vault } else if operation == "prepare_checkout" { if item.Type != "card" || item.Spec.Provider != "agentcard" { return fmt.Errorf("prepare_checkout requires an AgentCard card") @@ -380,7 +416,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if item.Spec.Provider == "kernel" { return kernelVaultRequestError(err, operation) } - if operation == "authorize" && linkCard { + if operation == "authorize" && linkPurchase { return linkAuthorizeError(err, params != nil && params.Authorize != nil) } return util.CleanedUpSdkError{Err: err} @@ -388,7 +424,10 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if isPasskeyOperation(operation) { return printVaultPasskeyOperationResult(response, operation, output) } - if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { + if operation == "add_payment_method" || operation == "remove_payment_method" { + return printVaultPaymentMethodOperationResult(response, operation, output) + } + if response == nil || (response.Type != "card" && response.Type != "payment_request" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { if item.Spec.Provider == "kernel" { return fmt.Errorf("unexpected vault operation response; %s", kernelVaultRetryGuidance(operation)) } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 0e958b43..42306604 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -55,7 +55,8 @@ func newVaultsCommand() *cobra.Command { Long: `collect user credentials and manage payment credentials. do not use credential items to store, collect, or fill credit card data. -use wallet and card item types for credit cards and payment checkout instead. +use wallet and card item types for credit cards and payment checkout instead; where enabled, +use payment_request items for one Link or KERNEL purchase. ` + vaultCredentialPathsHelp + ` @@ -87,36 +88,41 @@ vault names, item keys, and project ownership are immutable. 1. create/select a vault, then create a provider wallet and follow its returned action. for kernel, share the hosted card_enrollment url; card details stay on that page. -2. inspect wallets payment-methods for eligibility and reasons before creating a card. +2. inspect wallets payment-methods for eligibility and reasons before creating a purchase. for Link, select a payment method id explicitly. 3. attach the vault with browsers create --vault ; attachment is required for - authorizing a Link card at a checkout and for fill. for Link, reach the final checkout - page before creating the card. -4. create a card request with --provider and --spec json. + authorizing a Link card at a checkout and for fill; the same applies to a Link + payment_request. for Link, reach the final checkout + page before creating the purchase. +4. create a purchase with cards create, or where enabled use payment-requests create + for a Link or KERNEL purchase. pass --provider and --spec json. 5. inspect items get, then use items invoke only when advertised. follow the operation description and any returned provider action. -6. ready Link and kernel cards use only advertised fill with --params for browser checkout. - neither exposes aliases or supports egress substitution. kernel fill is locked to - merchant_url's origin; submit the merchant checkout before the card expires. - kernel cards have no stored billing fields; enter billing details on the merchant page. +6. ready Link and kernel cards use only advertised fill with --params for browser checkout; + equivalent payment_request items do too. neither exposes aliases or supports egress + substitution. kernel fill is locked to merchant_url's origin; submit the merchant + checkout before the purchase expires. + KERNEL card and payment_request purchases have no stored billing fields; enter billing + details on the merchant page. AgentCard-only checkout aliases support egress substitution with checkout hold, approval, and replay; they are not a fallback after fill. -7. after the user submits kernel card checkout, report the observed outcome once with - confirm_transaction when advertised. ` + kernelTransactionHelp + `. +7. after the user submits KERNEL card or payment_request checkout, report the observed + outcome once with confirm_transaction when advertised. ` + kernelTransactionHelp + `. in recovery_required, ` + vaultRecoveryHelp + ` ` + linkCardFlowHelp + ` -permitted checkout domains are displayed when returned. Link card fill is limited to the -origin of the checkout it was authorized at (or merchant_url's origin for a virtual card -authorized without a checkout); kernel card fill uses the exact origin of merchant_url; +permitted checkout domains are displayed when returned. Link card and payment_request fill +is limited to the origin where the purchase was authorized (or merchant_url's origin for a +virtual card authorized without a checkout); kernel card and payment_request fill uses the +exact origin of merchant_url; AgentCard domains are provider-assigned. there is no domain-setting api. never supply card data, OAuth codes, ciphertext, or secrets in shell arguments. use vault-provider-configs for client credentials and wallets create --tokens-file for imported Link grants; both accept protected files or stdin. do not automatically repeat failed, timed-out, rejected, or indeterminate payments: inspect the item, its events, and the merchant's order history, then follow the advertised operations. -when an item explicitly permits user-confirmed abandonment, delete that card before creating a replacement. +when an item explicitly permits user-confirmed abandonment, delete that purchase item before creating a replacement. json output preserves returned public fields but omits unknown/opaque provider data.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } @@ -166,7 +172,7 @@ each vault has its own key; it is created on first request and may be cached.`, addJSONOutputFlag(encryptionKey) cmd.AddCommand(create, list, get, encryptionKey, newVaultDeleteCommand(false)) - items := &cobra.Command{Use: "items", Short: "inspect item readiness or invoke advertised credential and payment operations", Long: "use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nuse invoke collect to obtain a collection url, invoke fill --spec-file to fill a browser, or invoke advertised passkey operations with --browser-id.\nuse webmcp invoke to call a live WebMCP tool with item fields bound to null input slots.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill/webmcp_invoke.\ncreate and edit credentials with vaults credentials; payment items use wallets/cards."} + items := &cobra.Command{Use: "items", Short: "inspect item readiness or invoke advertised credential and payment operations", Long: "use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nuse invoke collect to obtain a collection url, invoke fill --spec-file to fill a browser, or invoke advertised passkey operations with --browser-id.\nuse webmcp invoke to call a live WebMCP tool with item fields bound to null input slots.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill/webmcp_invoke.\ncreate and edit credentials with vaults credentials; payment items use wallets/cards/payment-requests."} 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)) @@ -198,27 +204,29 @@ each vault has its own key; it is created on first request and may be cached.`, invoke := &cobra.Command{Use: "invoke ", Short: "invoke an operation advertised by an item", Args: cobra.ExactArgs(3), PreRunE: vaultPreRun, Long: `retrieve the item and invoke only an operation listed in available_operations. collect returns a time-scoped url for the full credential form without clearing values. -authorize sends {"type":"authorize"} for payment authorization. for a Link card at the final -checkout page, add --browser-id and --page-url (both or neither) to send browser_id and -page_url; see Link card checkout below. +authorize sends {"type":"authorize"} for payment authorization. for a Link card or +payment_request at the final checkout page, add --browser-id and --page-url (both or +neither) to send browser_id and page_url; see Link checkout below. read the operation description and follow any approval requirements before invoking. fill requires --params json or --spec-file with browser_id (session id, not name) and the inputs the item's advertised fill description asks for: 1-32 ordered fields -(field, selector) for credentials, kernel cards, and Link virtual cards; no fields for a -Link Pay Token. do not include type, values, or frame ids. +(field, selector) for credentials, kernel card/payment_request items, and Link virtual +cards/payment requests; no fields for a Link Pay Token. do not include type, values, or frame ids. the vault must already be attached to the browser. page_url selects an existing page; fill never navigates. credentials use declared field names, must omit format, and may omit page_url only when the api can resolve a unique page. totp codes stay server-generated. -cards require an exact https page_url. Link virtual card fields: number, cvc, exp_month (MM), +payment card and payment_request items require an exact https page_url. Link virtual card fields: number, cvc, exp_month (MM), exp_year (YYYY), billing_name, billing_line1, billing_line2, billing_city, billing_state, billing_postal_code, billing_country. expiration requires format MM/YY or MM/YYYY. deprecated timeout_ms is accepted for compatibility but ignored; fill sets no operation deadline and adapts typing pace to value length. -kernel cards support number, expiration (format MM/YY or MM/YYYY), cvc, exp_month, and exp_year. -kernel cards have no stored billing fields: billing_* returns field_unavailable (400) before +kernel card and payment_request items support number, expiration (format MM/YY or MM/YYYY), cvc, exp_month, and exp_year. +kernel cards have no stored billing fields; kernel payment_request items have the same boundary: billing_* returns field_unavailable (400) before any write. enter billing details on the merchant page. the api searches the page and descendant frames, including payment iframes. fill is available for credential items and ready Link or kernel cards when advertised, not AgentCard. -Link and kernel cards do not expose aliases or support egress substitution. kernel card fill +equivalent Link and kernel payment_request items also use fill when advertised. +Link and kernel cards do not expose aliases or support egress substitution; payment_request +items have the same boundary. kernel fill is locked to merchant_url's origin and its one-time code expires; submit checkout before expiry. fill writes real values into the browser; unrestricted browser/cdp access can read them. fill never explicitly submits forms or clicks buttons, but input/change events may trigger site behavior. @@ -247,6 +255,15 @@ AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, FRAUD), amou (integer minor units 0..1000000, no greater than the approved amount), currency (three uppercase letters matching the approved purchase currency), and occurred_at (RFC3339). no other fields are accepted. ` + kernelTransactionHelp + `. +add_payment_method takes no parameters and is valid only when advertised by a connected +KERNEL wallet. each call creates a separate pending enrollment with a single-use url that +expires after 30 minutes; share it only with the cardholder and never retry automatically. +a failed or expired enrollment does not affect other methods. remove_payment_method +requires {"payment_method_id":""}. it blocks new purchases, fills, and confirmations +funded by that method without disconnecting the wallet or changing other methods. an +unresolved purchase can block removal; an uncertain provider outcome leaves removal_pending +and must be re-checked before an explicit retry. removal is not proof that a payment did +not occur. scoped keys need both vaults:use and vaults:write. restart_verification takes no parameters. in recovery_required, ` + vaultRecoveryHelp + ` it rotates the approval link without a provider call, only after a classified initial attestation failure with proof no intent was dispatched. other recovery states must not be retried. @@ -258,7 +275,7 @@ completed exits 0, failed/unknown exit nonzero with valid json retained on stdou ` + vaultPasskeyOperationHelp + ` -webmcp_invoke (credential items and ready Link cards when advertised; see items webmcp invoke --help +webmcp_invoke (credential items and ready Link card/payment_request items when advertised; see items webmcp invoke --help for the flag-based form) requires browser_id, the opaque live tool_ref and exact source.page_url from browsers webmcp list, input (public json object with a null at each bound slot; never vault values), and 1-32 bindings (field, input_path as an rfc 6901 json pointer to an existing null in @@ -278,14 +295,15 @@ and never retry automatically. 400/403/404/409 rejections mean the tool was not owner unchanged; invoke it once per credential. 1pw_access_request_status: no browser needed; 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 - timeout_ms 1-30000. the extension selects fields and submits. fill_submitted exits 0 +1pw_fill: browser_id and the exact page_url of an open login page on a requested + origin; target_id when several open pages have that exact url; entry_id when several + approved entries share that origin; optional timeout_ms 1-30000. the extension selects fields and submits. fill_submitted exits 0 and does not confirm sign-in; inspect the page. fill_failed and fill_unknown exit nonzero. 1pw_fill can submit the form, so never retry automatically. check the page first before retrying. 1pw_update_access_token (stored-token credentials only): --spec-file, never --params, - with access_token only. + with access_token and optional access_token_expires_at (RFC3339). omitting the + expiry clears the previously supplied expiry. 1pw_recover (credential accounts, no parameters): returns a new 1Password link that recovers a failed account connection. share it with the account owner, then run credentials connect again with the same key; never delete the item to recover. @@ -304,7 +322,9 @@ JSON 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 user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login","entry_id":"","target_id":""}' + kernel vaults items invoke checkout cardholder add_payment_method + kernel vaults items invoke checkout cardholder remove_payment_method --params '{"payment_method_id":""}' kernel vaults items invoke user-vault login register_passkey --browser-id --field passkey kernel vaults items invoke user-vault login arm_passkey --browser-id --field passkey kernel vaults items invoke user-vault login disarm_passkey --browser-id @@ -320,7 +340,7 @@ JSON } if cmd.Flags().Changed("spec-file") { if !vaultOperationTakesParams(args[2]) { - return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations with parameters; passkey operations use --browser-id and optional --field") + return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, remove_payment_method, and 1Password operations with parameters; passkey operations use --browser-id and optional --field") } data, err := readVaultJSONFile(cmd, "spec-file") if err != nil { @@ -347,7 +367,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, webmcp_invoke, prepare_checkout, confirm_transaction, 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, webmcp_invoke, prepare_checkout, confirm_transaction, remove_payment_method, 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; cardholder links are human-only, agents must share privately") @@ -359,7 +379,7 @@ JSON 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, - Long: "create a wallet at an immutable key and follow the returned provider action.\na connected Link wallet pays at checkout through cards; its returned description and the flow below describe how.\n" + vaultSpecHelp + vaultWalletSpecHelp + "\n" + linkCardFlowHelp, + Long: "create a wallet at an immutable key and follow the returned provider action.\na connected Link wallet pays at checkout through card or payment_request items; its returned description and the flow below describe how.\n" + vaultSpecHelp + vaultWalletSpecHelp + "\n" + linkCardFlowHelp, Example: ` kernel vaults wallets create checkout wallet-1 \ --provider link --spec '{ "authorization": { @@ -408,7 +428,9 @@ JSON cards := &cobra.Command{Use: "cards", Short: "configure card requests"} cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) - cmd.AddCommand(items, wallets, cards, newVaultCredentialsCommand()) + paymentRequests := &cobra.Command{Use: "payment-requests", Short: "configure one-purchase Link or KERNEL payment requests"} + paymentRequests.AddCommand(newVaultPaymentRequestCommand(false), newVaultPaymentRequestCommand(true)) + cmd.AddCommand(items, wallets, cards, paymentRequests, newVaultCredentialsCommand()) return cmd } @@ -473,6 +495,50 @@ an AgentCard recovery item that permits abandonment must be deleted after explic return cmd } +func newVaultPaymentRequestCommand(update bool) *cobra.Command { + use, short := "create", "create a one-purchase payment request without authorizing it" + if update { + use, short = "update", "update a payment request spec before checkout authorization" + } + cmd := &cobra.Command{ + Use: use + " --provider --spec ''", + Short: short, + Args: cobra.ExactArgs(2), + PreRunE: vaultPreRun, + Long: short + `. payment_request is available only where enabled for the organization. +Link and KERNEL payment requests are parented by their wallet and otherwise follow the +same purchase lifecycle and advertised operations as the corresponding card item. +AgentCard reusable cards remain card items. identical creates retrieve existing state; +never create another request to retry an uncertain payment. + +` + linkCardFlowHelp + "\n" + vaultSpecHelp + vaultPaymentRequestSpecHelp, + Example: " kernel vaults payment-requests " + use + ` checkout order-1 \ + --provider kernel --spec '{ + "wallet": "cardholder", + "payment_method_id": "", + "amount": 1234, + "currency": "usd", + "merchant_name": "Example Shop", + "merchant_url": "https://shop.example/checkout", + "merchant_country": "US" + }'`, + RunE: func(cmd *cobra.Command, args []string) error { + spec, err := vaultSpecFromFlags(cmd) + if err != nil { + return err + } + if string(spec["provider"]) == `"agentcard"` { + return fmt.Errorf("payment_request items support only link or kernel; AgentCard reusable cards use vaults cards") + } + return getVaultsHandler(cmd).SavePaymentRequest(cmd.Context(), args[0], args[1], param.Override[kernel.PaymentRequestVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) + }, + } + addVaultSpecFlags(cmd) + cmd.Flags().Lookup("provider").Usage = "provider: link or kernel (required)" + addVaultJSONOutputFlag(cmd) + return cmd +} + func addVaultSpecFlags(cmd *cobra.Command) { cmd.Flags().String("provider", "", "provider: link, agentcard, or kernel (required)") cmd.Flags().String("spec", "", "raw json specification object (required); see types and examples above") diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 7341b1a8..e4246f29 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -10,7 +10,6 @@ import ( "strings" kernel "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/option" "github.com/spf13/cobra" ) @@ -58,11 +57,14 @@ const vaultOnePasswordCredentialHelp = `1Password flow: 3. invoke 1pw_create_access_request once; it needs no browser. 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. + ready, declined, or failed. ready means approved, not signed in. for credentials made + with a customer-supplied access token, state.access_request.resolved returns the + non-secret granted references. 5. create a browser with the vault attached, open the login page, and invoke 1pw_fill - with browser_id and the exact page_url. when - several approved entries share that page's origin, pass entry_id from the - state.access_request entries in items get -o json. fill_submitted means the form + with browser_id and the exact page_url. when several open pages have that exact url, + pass target_id (for example tab.target_id from playwright.execute). when several + approved entries share that page's origin, pass entry_id from the state.access_request + entries in items get -o json. fill_submitted means the form was submitted, not that sign-in succeeded. invoke only advertised operations. 1pw_fill can submit the form, so never retry automatically. check the page first before retrying. never automatically retry an @@ -99,7 +101,8 @@ const vaultCredentialHelp = `create credentials for a website. KERNEL-hosted flow: do not use credential items to store, collect, or fill credit card data, including card numbers (pans), security codes (cvv/cvc), or expiration dates. use wallet and -card item types for credit cards and payment checkout instead. +card item types for credit cards and payment checkout instead; where enabled, use a +payment_request item for one Link or KERNEL purchase. first create a vault for the end user and attach it with browsers create --vault. use a protected json file or stdin, never secret values in shell arguments. @@ -267,13 +270,13 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ if expectedID != "" { request.ExpectedItemID = kernel.String(expectedID) } - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, vaultWriteRequestOptions()...) } else { spec, specErr := credentialSpecInput(data) if specErr != nil { return specErr } - item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemRequestParam{Type: "credential", Spec: spec}}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemRequestParam{Type: "credential", Spec: spec}}, vaultWriteRequestOptions()...) } if err != nil { return vaultCredentialError(err) @@ -347,7 +350,7 @@ func (c VaultsCmd) connectCredentialAccount(ctx context.Context, vault, key, out }, }, } - item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredentialAccount: &request}, option.WithMaxRetries(0)) + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredentialAccount: &request}, vaultWriteRequestOptions()...) if err != nil { return onePasswordConnectError(err) } diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 14b0b73a..9d44f0c2 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -8,7 +8,6 @@ import ( "strconv" kernel "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" ) @@ -114,7 +113,7 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } request.Fields = append(request.Fields, binding) } - response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfFill: &request}, option.WithMaxRetries(0)) + response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfFill: &request}, vaultWriteRequestOptions()...) if err != nil { messages := vaultFillErrorMessages if provider == "kernel" { @@ -224,7 +223,7 @@ const onePasswordFillNotSubmitted = "nothing was submitted by this request; insp // Only these statuses are returned before the extension is invoked. var onePasswordFillRejected = map[int]string{ 400: onePasswordFillNotSubmitted, 403: onePasswordFillNotSubmitted, 404: onePasswordFillNotSubmitted, - 409: "nothing was submitted by this request; if several approved entries match this page, pass entry_id from items get -o json. 1pw_fill can submit the form, so never retry automatically. check the page first before retrying.", + 409: "nothing was submitted by this request; if several open pages have page_url, pass target_id, and if several approved entries match its origin, pass entry_id from items get -o json. 1pw_fill can submit the form, so never retry automatically. check the page first before retrying.", } var onePasswordFillResultFields = vaultFieldsOf("type status error_code") @@ -232,7 +231,7 @@ var onePasswordFillResultFields = vaultFieldsOf("type status error_code") func (c VaultsCmd) onePasswordFill(ctx context.Context, vault, key string, request *kernel.VaultItemPerformOperationParams, output string) error { params := *request params.IDOrName = vault - response, err := c.vaults.Items.PerformOperation(ctx, key, params, option.WithMaxRetries(0)) + response, err := c.vaults.Items.PerformOperation(ctx, key, params, vaultWriteRequestOptions()...) if err != nil { var apiErr *kernel.Error if errors.As(err, &apiErr) && apiErr.StatusCode == 503 { diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 538e616e..72951b70 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -15,10 +15,13 @@ or normalization. never include card data, OAuth tokens, or provider secrets in const vaultRecoveryHelp = "only if restart_verification is advertised in available_operations, invoke it with explicit user approval each time to get a fresh spend_approval link. restart grants no spend consent: the cardholder must verify and explicitly approve spending again on the new link. if the result is unclear, re-get and share the new action if present; invoke once more only if restart_verification is still advertised and the user approves again. otherwise " + kernelSupportGuidance const vaultWalletSpecHelp = ` -for --provider kernel, use --spec '{}'. share the returned card_enrollment url -with the cardholder; never enter card details in the cli. use wallets get --wait 60 -to observe connection, then wallets payment-methods to inspect token eligibility. -a connected wallet may still be ineligible while network token enrollment runs. +for --provider kernel, use --spec '{}'. if creation returns card_enrollment, +share it with the cardholder; never enter card details in the cli. where the wallet +advertises add_payment_method, creation instead returns an empty connected wallet and +each card is enrolled through that operation. use wallets get --wait 60 to observe +connection, then wallets payment-methods to inspect card status and token eligibility. +a connected wallet may have zero cards or cards that are still ineligible while network +token enrollment runs. ` + kernelWalletHelp + `. ` + kernelEligibilityHelp + `. kernel wallets accept neither provider configuration flags nor --tokens-file. for Link and AgentCard, omit config selection to preserve managed defaults. @@ -54,12 +57,13 @@ type AgentCardWalletSpec = { user_id?: string; // usr_...; enrolled in this organization under the same config }; -// one card stored with KERNEL-managed credentials. creation returns a card_enrollment -// action: the cardholder enters the card on a KERNEL-hosted page, and the wallet -// connects once the card is stored. KERNEL then enrolls it for an agentic network -// token when the issuer supports it; until then payment-methods reports -// capabilities.single_use_card.eligible=false and authorize returns 400. -// the card number never reaches KERNEL or the cli. no provider config or tokens. +// cards stored with KERNEL-managed credentials. each is a wallet payment method. +// where add_payment_method is advertised, creation returns an empty connected wallet; +// invoke add_payment_method once for each card enrollment. otherwise creation returns +// the default card's card_enrollment action and connects after that card is stored. +// KERNEL enrolls stored cards for agentic network tokens when supported; until then +// payment-methods can report capabilities.single_use_card.eligible=false. +// card numbers never reach KERNEL or the cli. no provider config or tokens. type KernelWalletSpec = { provider: "kernel"; }; @@ -107,6 +111,7 @@ type AgentCardCardSpec = { type KernelCardSpec = { provider: "kernel"; wallet: string; // KERNEL wallet item key + payment_method_id?: string; // from wallets payment-methods; omit only for the legacy default card; a wallet with no default card requires it amount: number; // integer minor units; 1..1000000 currency: string; // iso 4217 three letters merchant_name: string; // 1..255 characters @@ -163,4 +168,66 @@ prepared checkout uses preparation merchant_origin instead. permitted domains are not configurable in the spec. Link card fill is limited to the origin of the checkout the card was authorized at; AgentCard domains are provider-assigned. +for KERNEL purchases, ` + kernelFundingStatusHelp + `. +` + +const vaultPaymentRequestSpecHelp = ` +payment_request items are one Link or KERNEL purchase parented by a wallet. they have +the same authorize, fill, confirmation, recovery, and deletion lifecycle as the +corresponding card purchase. availability is organization-controlled. AgentCard reusable +cards remain card items. create and update require type payment_request on the wire; the +cli supplies it. updates are subject to the same authorization-state restrictions as cards. + +type LinkPaymentRequestSpec = { + provider: "link"; + wallet: string; // connected Link wallet item key + payment_method_id: string; // from wallets payment-methods + amount: number; // integer minor units; 1..50000; with recurring, per-period limit + currency: string; // three letters + merchant_name: string; // 1..255 characters + merchant_url: string; // uri; virtual-card fill is limited to this origin + context: string; // at least 100 characters + line_items?: LinkLineItem[]; + totals?: LinkTotal[]; + metadata?: Record; + recurring?: LinkRecurring; +}; + +type KernelPaymentRequestSpec = { + provider: "kernel"; + wallet: string; // KERNEL wallet item key + payment_method_id: string; // required; active eligible method from wallets payment-methods + amount: number; // integer minor units; 1..1000000 + 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 + merchant_category?: string; // actual category; omit rather than invent + merchant_category_code?: string; // actual four-digit MCC, not 0000; omit rather than invent +}; + +type LinkRecurring = { + interval: "day" | "week" | "month" | "year"; + interval_count?: number; // integer >= 1 +}; + +type LinkLineItem = { + name: string; + quantity?: number; + unit_amount?: number; + description?: string; + sku?: string; + url?: string; + image_url?: string; + product_url?: string; + totals?: LinkTotal[]; +}; + +type LinkTotal = { type: string; display_text: string; amount: number }; + +KERNEL payment requests must select payment_method_id and remain bound to it. removing +that method makes the purchase unusable rather than moving it to another card. for KERNEL +purchases, ` + kernelFundingStatusHelp + `. Link payment requests follow the Link checkout +flow below. never retry an uncertain payment by creating another payment_request; +reconcile the original item and merchant outcome. ` diff --git a/cmd/vaults_kernel_errors.go b/cmd/vaults_kernel_errors.go index fe9d12d2..2c93c90e 100644 --- a/cmd/vaults_kernel_errors.go +++ b/cmd/vaults_kernel_errors.go @@ -10,11 +10,32 @@ import ( "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" ) var kernelVaultErrorURL = regexp.MustCompile(`(?i)https?://\S+`) var kernelVaultErrorCode = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) +// The SDK adds an Idempotency-Key to every non-GET request. Go's transport treats +// that header plus GetBody as permission to replay a request after some connection +// failures, independently of the SDK retry setting. Vault writes can have side +// effects and must never be replayed implicitly, so remove the transport's replay +// body factory. Keep the key on requests with bodies for server-side classification; +// bodyless writes must drop it because the header alone makes them replayable. +func vaultWriteRequestOptions() []option.RequestOption { + return []option.RequestOption{ + option.WithMaxRetries(0), + option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + req.GetBody = nil + if req.Body == nil || req.Body == http.NoBody { + req.Header.Del("Idempotency-Key") + req.Header.Del("X-Idempotency-Key") + } + return next(req) + }), + } +} + type kernelVaultTransportError struct { message string cause error @@ -57,6 +78,10 @@ func kernelVaultRetryGuidance(operation string) string { switch operation { case "create": return "retry create once with the identical key and identical spec; it retrieves the existing item if creation succeeded. do not use a new key to recover a lost create response" + case "add_payment_method": + return "re-get the wallet with payment_methods expanded before another call; each call creates a separate enrollment link, so never repeat automatically" + case "remove_payment_method": + return "re-get the wallet with payment_methods expanded; retry removal only when the same method remains removal_pending and remove_payment_method is freshly advertised. reconcile any unresolved purchase first" case "authorize", "confirm_transaction", "restart_verification": guidance := "re-get the item and inspect state/events; invoke " + operation + " once more only if freshly advertised in available_operations, never in an automatic loop. otherwise follow the current state. never replay merchant checkout" if operation == "restart_verification" { @@ -108,14 +133,16 @@ func retryAfterWait(apiErr *kernel.Error) string { return "wait for the rate limit to clear" } -const kernelSupportGuidance = "do not retry payment, delete, or replace the item. contact support@kernel.sh privately with project, vault, item key, status_reason and relevant event timestamps (never card data, api keys or capability urls) to reconcile the original operation and establish whether a new purchase is safe; do not assume a new card is safe or promise a resulting state" +const kernelSupportGuidance = "do not retry payment, delete, or replace the item. contact support@kernel.sh privately with project, vault, item key, status_reason and relevant event timestamps (never card data, api keys or capability urls) to reconcile the original operation and establish whether a new purchase is safe; do not assume a replacement purchase is safe or promise a resulting state" const kernelTransactionHelp = "report one observed merchant outcome only when confirm_transaction is advertised. PENDING means the merchant explicitly confirms an accepted transaction is pending; ERROR means an explicit merchant transaction error, not transport loss or an ambiguous processing page. these are final reports, not polling states. unknown means no reliable merchant outcome: ask the user to check order confirmation, merchant account or statement, then report the confirmed outcome even after card expiry. never infer success from fill. if reporting fails or the response is lost, re-get and send the identical observed report once more only if confirm_transaction is freshly advertised; otherwise observe state/events (recorded, in flight or recovery). never repeat merchant checkout" const kernelEligibilityHelp = "eligible=false means do not authorize. for network_token_pending, re-check later at most once in this attempt; network_token_unsupported or network_token_failed needs a different card. if still ineligible or unknown, ask the cardholder before enrolling a different card under a new wallet key; do not poll indefinitely or replay an uncertain purchase" -const kernelWalletHelp = "share card_enrollment only with the intended cardholder privately, never open it for them. reconnect_required: share the new card_enrollment action. degraded: with user confirmation, delete the wallet only if the api permits it before creating a new wallet under a new key for one enrollment; reconcile unresolved child cards first. connected does not imply token eligibility; inspect wallets payment-methods" +const kernelFundingStatusHelp = "funding_status is separate from purchase status. unavailable means the selected payment method is being removed or needs recovery, so authorize, fill, and confirmation are refused until it recovers. removed means the method is gone and this purchase cannot be paid; reconcile its prior outcome before creating a new purchase with another method. neither value rewrites what already happened to the purchase" + +const kernelWalletHelp = "share card_enrollment only with the intended cardholder privately, never open it for them. where add_payment_method is advertised, use it to enroll each card; remove_payment_method removes one method without disconnecting the wallet. reconnect_required marks a legacy wallet awaiting an operator upgrade; reads preserve its state, and payments and deletion are refused until the upgrade. degraded: with user confirmation, delete the wallet only if the api permits it before creating a new wallet under a new key; reconcile unresolved child purchases first. connected may have zero cards and does not imply token eligibility; inspect wallets payment-methods" -const kernelReplacementHelp = "after user confirmation, delete only if the api permits it and create one new card under a new key for a new purchase; reconcile unresolved outcomes first" +const kernelReplacementHelp = "after user confirmation, delete only if the api permits it and create one new purchase item under a new key; reconcile unresolved outcomes first" const kernelTerminalHelp = "merchant decline: report the observed DECLINED outcome when confirm_transaction is advertised. expired approval link or code before checkout: do not submit. if checkout was submitted, reconcile and report the observed outcome first (reporting may remain available after expiry). if never submitted, or after a confirmed decline, " + kernelReplacementHelp + "; ask for a different card or merchant after decline. unknown outcome: ask the user to check the merchant order page, email or statement and report only after they confirm; never guess or repeat checkout" diff --git a/cmd/vaults_kernel_payment_test.go b/cmd/vaults_kernel_payment_test.go index 31b8f441..38806e72 100644 --- a/cmd/vaults_kernel_payment_test.go +++ b/cmd/vaults_kernel_payment_test.go @@ -134,6 +134,27 @@ func TestKernelCardCreateAuthorizeAndApproval(t *testing.T) { assert.Equal(t, 3, calls) } +func TestKernelCardSelectsPaymentMethod(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec map[string]json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, `"card-2"`, string(body.Spec["payment_method_id"])) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, strings.Replace(kernelCardFixture, `"merchant_country":"US"`, `"merchant_country":"US","payment_method_id":"card-2"`, 1)) + }) + spec := `{"wallet":"cardholder","payment_method_id":"card-2","amount":1200,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}` + out, _, err := executeVaultCommand(t, client, "vaults", "cards", "create", "checkout", "order-1", "--provider", "kernel", "--spec", spec, "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"payment_method_id": "card-2"`) + + cmd, _, err := newVaultsCommand().Find([]string{"cards", "create"}) + require.NoError(t, err) + assert.Contains(t, cmd.Long, "payment_method_id?: string") + assert.Contains(t, cmd.Long, "wallet with no default card") +} + func TestKernelReadyCardUsesAdvertisedFill(t *testing.T) { ready := strings.Replace(kernelCardFixture, `"status":"requested"`, `"status":"ready"`, 1) ready = strings.Replace(ready, `"type":"authorize","description":"Authorize this purchase"`, `"type":"fill","description":"Fill checkout fields"`, 1) diff --git a/cmd/vaults_kernel_recovery_test.go b/cmd/vaults_kernel_recovery_test.go index 12448afe..de7fe9da 100644 --- a/cmd/vaults_kernel_recovery_test.go +++ b/cmd/vaults_kernel_recovery_test.go @@ -218,16 +218,19 @@ func TestKernelRecoveryJSONAndStateGuidance(t *testing.T) { _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") require.NoError(t, err) switch status { - case "degraded", "reconnect_required": - assert.Contains(t, human, "delete the wallet only if the api permits it") - assert.Contains(t, human, "share the new card_enrollment") + case "degraded": + assert.Contains(t, human, "degraded: with user confirmation, delete the wallet only if the api permits it") + case "reconnect_required": + assert.Contains(t, human, "legacy wallet awaiting an operator upgrade") + assert.Contains(t, human, "payments and deletion are refused until the upgrade") + assert.NotContains(t, human, "share the new card_enrollment") case "requested": assert.Contains(t, human, "Visa may require") assert.NotContains(t, human, "merchant decline") case "recovery_required": assert.Contains(t, human, "support@kernel.sh") assert.Contains(t, human, "project, vault, item key") - assert.Contains(t, human, "do not assume a new card is safe") + assert.Contains(t, human, "do not assume a replacement purchase is safe") out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1", "-o", "json") require.NoError(t, err) assert.NotContains(t, out, `"authorize"`) diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index 45fcc19e..4ef16415 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -14,12 +14,13 @@ import ( // linkCardFlowHelp follows the API's Link wallet and card operation descriptions. // Every command that documents Link checkout includes it, so the flow reads the same everywhere. -const linkCardFlowHelp = `Link card checkout (each item's available_operations descriptions are authoritative): +const linkCardFlowHelp = `Link card checkout and payment_request checkout (each item's available_operations descriptions are authoritative): 1. reach the final checkout page in a browser created with --vault. note the amount, currency, merchant name and url, and a payment_method_id from wallets payment-methods. -2. create the card with cards create --provider link. this does not contact Link; the - card is requested and advertises authorize. to change the amount or merchant before - authorizing, use cards update. +2. create the card with cards create --provider link, or where enabled create a + payment_request with payment-requests create --provider link. this does not contact + Link; the item is requested and advertises authorize. to change it before authorizing, + use the matching cards update or payment-requests update command. 3. once the user has approved this purchase, authorize with items invoke authorize --browser-id --page-url . KERNEL inspects the checkout, chooses how the credential is delivered (a Link Pay @@ -38,8 +39,9 @@ destination_denied, timeout, or browser_error, no payment request was made: make checkout is open in exactly one tab at page_url, then authorize again. if checkout inspection keeps failing, authorize without --browser-id and --page-url to issue a virtual card that fills any page on the merchant_url origin. once a card is authorized at a checkout, cards update -returns 409: delete the card and create a new one. if authorization is declined or expires, -read status_reason, then delete the card and create a new one. if the result of submitting +returns 409: delete the card and create a new one. the same rule applies to payment-requests +update: delete the payment_request and create a new one. if authorization is declined or +expires, read status_reason, then delete the item and create a new one. if the result of submitting payment is unclear, check the merchant's order confirmation or order history before paying again. for a recurring card, amount is the limit for each declared period. the merchant schedules later charges and the same card remains ready until it expires or is deleted; do not create a diff --git a/cmd/vaults_no_replay_test.go b/cmd/vaults_no_replay_test.go new file mode 100644 index 00000000..a34bd3d3 --- /dev/null +++ b/cmd/vaults_no_replay_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVaultWritesAreNeverTransportReplayed(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"wallet":"cardholder","payment_method_id":"card-1","amount":1200,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout"}` + + t.Run("body", func(t *testing.T) { + writes := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = fmt.Fprint(w, kernelPaymentRequestFixture) + return + } + writes++ + conn, _, err := w.(http.Hijacker).Hijack() + require.NoError(t, err) + _ = conn.Close() + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "checkout") + require.NoError(t, err) // Prime a reusable connection so net/http's replay path is exercised. + _, _, err = executeVaultCommand(t, client, "vaults", "payment-requests", "update", "user", "checkout", "--provider", "kernel", "--spec", spec) + require.Error(t, err) + assert.Equal(t, 1, writes) + }) + + t.Run("bodyless", func(t *testing.T) { + writes := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = fmt.Fprint(w, kernelPaymentRequestFixture) + return + } + writes++ + conn, _, err := w.(http.Hijacker).Hijack() + require.NoError(t, err) + _ = conn.Close() + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "checkout") + require.NoError(t, err) + _, _, err = executeVaultCommand(t, client, "vaults", "items", "delete", "user", "checkout", "--yes") + require.Error(t, err) + assert.Equal(t, 1, writes) + }) +} diff --git a/cmd/vaults_onepassword_resolved_test.go b/cmd/vaults_onepassword_resolved_test.go new file mode 100644 index 00000000..6b84e8c4 --- /dev/null +++ b/cmd/vaults_onepassword_resolved_test.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVaultOnePasswordResolvedReferencesAreDisplayed(t *testing.T) { + fixture := `{ + "id":"credential-2","key":"github","type":"credential","version":1, + "spec":{"provider":"1password","requests":{"version":2,"entries":[{"id":"entry-1","type":"login","parameters":{"website":"https://github.com"}}]}}, + "state":{"provider":"1password","status":"ready","access_request":{"id":"req-1","state":"approved","has_autofill_token":false,"granted_count":1,"resolved":[{"reference":"op://Private/GitHub/login","entryId":"entry-1","secret":"never-print"}]}}, + "available_operations":[{"type":"1pw_fill","description":"Fill the approved login"}], + "available_expansions":[] + }` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "github", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"resolved"`) + assert.Contains(t, out, `"reference": "op://Private/GitHub/login"`) + assert.Contains(t, out, `"entryId": "entry-1"`) + assert.NotContains(t, out, "never-print") + + _, text, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "github") + require.NoError(t, err) + assert.Contains(t, text, "resolved 1Password reference") + assert.Contains(t, text, "op://Private/GitHub/login") + assert.Contains(t, text, "entry entry-1") + assert.NotContains(t, text, "never-print") +} diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index cd07bf78..22f880e9 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -179,6 +179,7 @@ func TestOnePasswordOperationValidation(t *testing.T) { {"1pw_fill", `{"browser_id":"b"}`, "page_url"}, {"1pw_fill", `{"browser_id":"b","page_url":"https://github.com/login","timeout_ms":0}`, "timeout_ms"}, {"1pw_fill", `{"browser_id":"b","page_url":"https://github.com/login","entry_id":""}`, "entry_id"}, + {"1pw_fill", `{"browser_id":"b","page_url":"https://github.com/login","target_id":""}`, "target_id"}, {"1pw_update_access_token", `{"access_token":"token-secret"}`, "only through --spec-file"}, {"1pw_recover", `{}`, "takes no parameters"}, } { @@ -302,8 +303,12 @@ func TestCredentialHelpPresentsBothPaths(t *testing.T) { } assert.Contains(t, invoke.Long, "--spec-file, never --params") assert.Contains(t, invoke.Example, `"entry_id":""`) + assert.Contains(t, invoke.Example, `"target_id":""`) + assert.Contains(t, invoke.Long, "target_id when several open pages") + assert.Contains(t, invoke.Long, "optional access_token_expires_at (RFC3339)") + assert.Contains(t, invoke.Long, "expiry clears the previously supplied expiry") for _, help := range []string{invoke.Long, invoke.Example, credentials.Long, credentials.Example, connect.Long} { - for _, removed := range []string{"1pw_request_access", "1pw_poll_access", "1pw_reconcile", "account_id", "Family", "customer_managed", "access_token_expires_at", "RFC 3339"} { + for _, removed := range []string{"1pw_request_access", "1pw_poll_access", "1pw_reconcile", "account_id", "Family", "customer_managed", "RFC 3339"} { assert.NotContains(t, help, removed) } } @@ -345,8 +350,8 @@ func TestOnePasswordFillEntryAndTokenUpdateRequests(t *testing.T) { for _, tc := range []struct { operation, params, body, response string }{ - {"1pw_fill", `{"browser_id":"browser-1","page_url":"https://github.com/login","entry_id":"entry-1"}`, `{"type":"1pw_fill","browser_id":"browser-1","page_url":"https://github.com/login","entry_id":"entry-1"}`, `{"type":"1pw_fill","status":"fill_submitted"}`}, - {"1pw_update_access_token", `{"access_token":"token-secret"}`, `{"type":"1pw_update_access_token","access_token":"token-secret"}`, onePasswordCredentialWithOperation("1pw_update_access_token")}, + {"1pw_fill", `{"browser_id":"browser-1","page_url":"https://github.com/login","entry_id":"entry-1","target_id":"target-1"}`, `{"type":"1pw_fill","browser_id":"browser-1","page_url":"https://github.com/login","entry_id":"entry-1","target_id":"target-1"}`, `{"type":"1pw_fill","status":"fill_submitted"}`}, + {"1pw_update_access_token", `{"access_token":"token-secret","access_token_expires_at":"2026-10-01T00:00:00Z"}`, `{"type":"1pw_update_access_token","access_token":"token-secret","access_token_expires_at":"2026-10-01T00:00:00Z"}`, onePasswordCredentialWithOperation("1pw_update_access_token")}, } { t.Run(tc.operation, func(t *testing.T) { posts := 0 @@ -371,7 +376,7 @@ func TestOnePasswordFillEntryAndTokenUpdateRequests(t *testing.T) { calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) - for _, params := range []string{`{"access_token":""}`, `{"access_token":"token-secret","access_token_expires_at":"2026-10-01T00:00:00Z"}`, `{"access_token":"token-secret","integration_key":"key-secret"}`} { + for _, params := range []string{`{"access_token":""}`, `{"access_token":"token-secret","access_token_expires_at":""}`, `{"access_token":"token-secret","access_token_expires_at":"not-a-date"}`, `{"access_token":"token-secret","access_token_expires_at":null}`, `{"access_token":"token-secret","integration_key":"key-secret"}`} { _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "github", "1pw_update_access_token", "--spec-file", credentialSpecFile(t, params)) require.Error(t, err, params) assert.NotContains(t, err.Error(), "-secret") diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 96f4e2c2..9e4e832d 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -7,6 +7,7 @@ import ( "net/url" "slices" "strings" + "time" kernel "github.com/kernel/kernel-go-sdk" ) @@ -17,9 +18,11 @@ type vaultOperationParams struct { WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam Checkout *kernel.VaultCheckoutContextParam ConfirmTransaction *vaultConfirmTransactionParams - // Passkey and OnePassword are complete operation request bodies; Invoke supplies the vault. - Passkey *kernel.VaultItemPerformOperationParams - OnePassword *kernel.VaultItemPerformOperationParams + // Passkey, OnePassword, and PaymentMethod are complete operation request bodies; + // Invoke supplies the vault. + Passkey *kernel.VaultItemPerformOperationParams + OnePassword *kernel.VaultItemPerformOperationParams + PaymentMethod *kernel.VaultItemPerformOperationParams } func isOnePasswordOperation(operation string) bool { @@ -28,7 +31,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 == "webmcp_invoke" || operation == "prepare_checkout" || operation == "confirm_transaction" || (isOnePasswordOperation(operation) && operation != "1pw_recover") + return operation == "fill" || operation == "webmcp_invoke" || operation == "prepare_checkout" || operation == "confirm_transaction" || operation == "remove_payment_method" || (isOnePasswordOperation(operation) && operation != "1pw_recover") } type vaultFillParams struct { @@ -120,6 +123,33 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } return &vaultOperationParams{OnePassword: request}, nil } + if operation == "add_payment_method" { + if paramsSet { + return nil, fmt.Errorf("add_payment_method takes no parameters") + } + return &vaultOperationParams{PaymentMethod: &kernel.VaultItemPerformOperationParams{ + OfAddPaymentMethod: &kernel.AddPaymentMethodVaultItemOperationRequestParam{Type: kernel.AddPaymentMethodVaultItemOperationRequestTypeAddPaymentMethod}, + }}, nil + } + if operation == "remove_payment_method" { + if !paramsSet { + return nil, fmt.Errorf("remove_payment_method requires --params or --spec-file with payment_method_id") + } + object, err := vaultParamsObject(raw, "payment_method_id") + if err != nil { + return nil, err + } + var paymentMethodID string + if json.Unmarshal(object["payment_method_id"], &paymentMethodID) != nil || strings.TrimSpace(paymentMethodID) == "" || len(paymentMethodID) > 128 { + return nil, fmt.Errorf("payment_method_id must be a non-empty string of at most 128 bytes") + } + return &vaultOperationParams{PaymentMethod: &kernel.VaultItemPerformOperationParams{ + OfRemovePaymentMethod: &kernel.RemovePaymentMethodVaultItemOperationRequestParam{ + Type: kernel.RemovePaymentMethodVaultItemOperationRequestTypeRemovePaymentMethod, + PaymentMethodID: paymentMethodID, + }, + }}, nil + } if operation == "confirm_transaction" { if !paramsSet { return nil, fmt.Errorf("confirm_transaction requires --params or --spec-file") @@ -152,7 +182,7 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations; authorize takes --browser-id and --page-url, passkey operations take --browser-id and optional --field, and restart_verification takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, remove_payment_method, and 1Password operations; authorize takes --browser-id and --page-url, passkey operations take --browser-id and optional --field, and add_payment_method and restart_verification take no parameters") } return nil, nil } @@ -225,8 +255,8 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe allowed := map[string]string{ "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", + "1pw_fill": "browser_id page_url target_id entry_id timeout_ms", + "1pw_update_access_token": "access_token access_token_expires_at", }[operation] if allowed == "" { return nil, fmt.Errorf("unsupported 1Password operation %q", operation) @@ -240,6 +270,17 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe if json.Unmarshal(object["access_token"], &request.AccessToken) != nil || strings.TrimSpace(request.AccessToken) == "" || len(request.AccessToken) > 16384 { return nil, fmt.Errorf("access_token must be a non-empty string of at most 16384 bytes") } + if value, ok := object["access_token_expires_at"]; ok { + var text string + if json.Unmarshal(value, &text) != nil { + return nil, fmt.Errorf("access_token_expires_at must be an RFC3339 date-time") + } + expiresAt, err := time.Parse(time.RFC3339, text) + if err != nil { + return nil, fmt.Errorf("access_token_expires_at must be an RFC3339 date-time") + } + request.AccessTokenExpiresAt = kernel.Opt(expiresAt) + } return &kernel.VaultItemPerformOperationParams{Of1pwUpdateAccessToken: &request}, nil } switch operation { @@ -291,6 +332,13 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe } request.TimeoutMs = kernel.Opt(*timeout) } + if value, ok := object["target_id"]; ok { + var targetID string + if json.Unmarshal(value, &targetID) != nil || strings.TrimSpace(targetID) == "" { + return nil, fmt.Errorf("target_id must be a non-empty CDP target ID for an open page") + } + request.TargetID = kernel.Opt(targetID) + } if value, ok := object["entry_id"]; ok { var entryID string if json.Unmarshal(value, &entryID) != nil || strings.TrimSpace(entryID) == "" { diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index d05c23fd..c1872022 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -33,6 +33,8 @@ var onePasswordRequestEntryFields = vaultOutputFields{ var onePasswordRequestFields = vaultOutputFields{"version": nil, "goal": nil, "entries": onePasswordRequestEntryFields} var vaultMethodFields = vaultOutputFields{ "id": nil, "provider": nil, "type": nil, "is_default": nil, + "status": nil, "status_reason": nil, "expires_at": nil, + "action": vaultFieldsOf("name url"), "display": vaultFieldsOf("label brand last4"), "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } @@ -82,12 +84,15 @@ var vaultItemFields = vaultOutputFields{ "access_request": { "id": nil, "state": nil, "goal": nil, "createdAt": nil, "has_autofill_token": nil, "granted_count": nil, "request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields, + "resolved": vaultFieldsOf("reference entryId"), }, - "fields": {"*": vaultFieldsOf("has_value type")}, - "masks": vaultFieldsOf("brand last4 token_last4"), - "aliases": vaultFieldsOf("number cvc exp_month exp_year"), - "preparation": vaultFieldsOf("id status browser_id merchant_origin environment psp created_at expires_at approval_url"), - "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), + "funding_status": nil, + "payment_method_id": nil, + "fields": {"*": vaultFieldsOf("has_value type")}, + "masks": vaultFieldsOf("brand last4 token_last4"), + "aliases": vaultFieldsOf("number cvc exp_month exp_year"), + "preparation": vaultFieldsOf("id status browser_id merchant_origin environment psp created_at expires_at approval_url"), + "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), }, } var vaultEventFields = vaultOutputFields{ @@ -366,7 +371,7 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s command += " --field ''" } } - if op.Type == "authorize" && item.Type == "card" && item.Spec.Provider == "link" && item.State.Status == "requested" { + if op.Type == "authorize" && (item.Type == "card" || item.Type == "payment_request") && item.Spec.Provider == "link" && item.State.Status == "requested" { command += " --browser-id '' --page-url ''" } pterm.Printf("invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) @@ -421,6 +426,13 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { entryRow(entry.ID, entry.Parameters.Website) } } + for _, resolved := range item.State.AccessRequest.Resolved { + label := resolved.Reference + if resolved.EntryID != "" { + label += " (entry " + resolved.EntryID + ")" + } + rows = append(rows, []string{"resolved 1Password reference", label}) + } } } else if item.Spec.Provider == "managed_auth" { rows = append(rows, []string{"managed auth connection (immutable)", item.Spec.ConnectionID}) @@ -444,24 +456,24 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.State.StatusReason != "" { rows = append(rows, []string{"status reason", item.State.StatusReason}) } - if item.Type == "card" { + if item.Type == "card" || item.Type == "payment_request" { merchant := item.Spec.MerchantName if item.Spec.Provider == "agentcard" { merchant = item.Spec.Merchant } rows = append(rows, []string{"wallet key", item.Spec.Wallet}, []string{"merchant", merchant}, []string{"amount (minor units)", fmt.Sprintf("%d %s", item.Spec.Amount, item.Spec.Currency)}) - if item.Spec.Provider == "link" { + if item.Spec.PaymentMethodID != "" { rows = append(rows, []string{"payment method id", item.Spec.PaymentMethodID}) - if item.Spec.JSON.Recurring.Valid() { - intervalCount := "1 (default)" - if item.Spec.Recurring.JSON.IntervalCount.Valid() { - intervalCount = fmt.Sprint(item.Spec.Recurring.IntervalCount) - } - rows = append(rows, - []string{"recurring interval", item.Spec.Recurring.Interval}, - []string{"recurring interval count", intervalCount}, - ) + } + if item.Spec.Provider == "link" && item.Spec.JSON.Recurring.Valid() { + intervalCount := "1 (default)" + if item.Spec.Recurring.JSON.IntervalCount.Valid() { + intervalCount = fmt.Sprint(item.Spec.Recurring.IntervalCount) } + rows = append(rows, + []string{"recurring interval", item.Spec.Recurring.Interval}, + []string{"recurring interval count", intervalCount}, + ) } if item.Spec.Provider == "agentcard" && item.Spec.CheckoutOrigin != "" { rows = append(rows, []string{"checkout origin", item.Spec.CheckoutOrigin}) @@ -472,6 +484,12 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "kernel" && item.Spec.MerchantCountry != "" { rows = append(rows, []string{"merchant country", item.Spec.MerchantCountry}) } + if item.State.JSON.FundingStatus.Valid() { + rows = append(rows, []string{"funding status", string(item.State.FundingStatus)}) + } + if item.State.JSON.PaymentMethodID.Valid() && item.State.PaymentMethodID != item.Spec.PaymentMethodID { + rows = append(rows, []string{"funding payment method id", item.State.PaymentMethodID}) + } if masks := item.State.Masks; masks.Last4 != "" || masks.TokenLast4 != "" { rows = append(rows, []string{"card last4", util.OrDash(masks.Last4)}) if masks.TokenLast4 != "" { @@ -569,11 +587,11 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction 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.") } - pterm.Info.Println("do not use credential items for credit card data. use wallet and card item types for credit cards and payment checkout instead.") + pterm.Info.Println("do not use credential items for credit card data. use wallet and card item types for credit cards and payment checkout instead; where enabled, use payment_request for one Link or KERNEL purchase.") pterm.Info.Println("ready means required fields are populated, not that login succeeded. fill only when advertised; fill does not submit the form. passkey fields are never filled: use register_passkey, arm_passkey, and disarm_passkey only when advertised.") return } - if item.Type == "card" { + if item.Type == "card" || item.Type == "payment_request" { if item.Spec.Provider == "kernel" { switch item.State.Status { case "requested", "pending_authorization", "ready": @@ -593,25 +611,35 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction } pterm.Info.Println("preparations are single-use, including after failure or expiry. preparation consumed means claimed, not payment success. do not retry automatically.") } - card := item.AsCard() - for _, expansion := range card.AvailableExpansions { - pterm.Printf("available expansion: %s — %s\n", expansion.Type, expansion.Description) + if item.Type == "card" { + card := item.AsCard() + for _, expansion := range card.AvailableExpansions { + pterm.Printf("available expansion: %s — %s\n", expansion.Type, expansion.Description) + } + } else { + request := item.AsPaymentRequest() + for _, expansion := range request.AvailableExpansions { + pterm.Printf("available expansion: %s — %s\n", expansion.Type, expansion.Description) + } } if item.State.JSON.Aliases.Valid() { pterm.Info.Println("aliases are non-secret checkout values. use only in a browser created with this vault attached; ready does not mean paid.") } switch item.Spec.Provider { case "kernel": - pterm.Info.Println(kernelTransactionHelp + ". kernel cards have no stored billing fields; billing_* returns field_unavailable. enter billing details on the merchant page.") + pterm.Info.Println(kernelTransactionHelp + ". kernel card and payment_request purchases have no stored billing fields; billing_* returns field_unavailable. enter billing details on the merchant page.") + if item.State.JSON.FundingStatus.Valid() { + pterm.Info.Println(kernelFundingStatusHelp + ".") + } case "link": - pterm.Info.Println("fill never submits payment; submit the checkout yourself. if the result of submitting payment is unclear, check the merchant's order confirmation or order history before paying again. if authorization is declined or expires, delete this card and create a new one.") + pterm.Info.Println("fill never submits payment; submit the checkout yourself. if the result of submitting payment is unclear, check the merchant's order confirmation or order history before paying again. if authorization is declined or expires, delete this purchase item and create a new one.") if item.Spec.JSON.Recurring.Valid() { - pterm.Info.Println("this recurring card remains ready for merchant-scheduled charges until it expires or is deleted; do not create a new card each period. deleting the card, wallet, or vault revokes the Link credential but does not cancel the merchant subscription. if deletion returns 409 provider_revocation_pending, retry deletion.") + pterm.Info.Println("this recurring Link purchase remains ready for merchant-scheduled charges until it expires or is deleted; do not create a new card each period, or a new payment_request for the same recurring purchase. deleting the item, wallet, or vault revokes the Link credential but does not cancel the merchant subscription. if deletion returns 409 provider_revocation_pending, retry deletion.") } default: - pterm.Info.Println("inspect items events for payment outcomes. never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") + pterm.Info.Println("inspect items events for payment outcomes. never retry automatically; if recovery permits abandonment, delete the purchase item only after explicit user confirmation before creating a replacement.") } - } else { + } else if item.Type == "wallet" { wallet := item.AsWallet() if item.Description != "" { pterm.Info.Println(item.Description) @@ -637,25 +665,66 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction } } +var vaultAddPaymentMethodResultFields = vaultOutputFields{"type": nil, "payment_method": vaultMethodFields} +var vaultRemovePaymentMethodResultFields = vaultFieldsOf("type status payment_method_id") + +func printVaultPaymentMethodOperationResult(response *kernel.VaultItemOperationResponseUnion, operation, output string) error { + if response == nil || response.Type != operation { + return fmt.Errorf("unexpected %s response; inspect the wallet before retrying", operation) + } + fields := vaultAddPaymentMethodResultFields + if operation == "remove_payment_method" { + fields = vaultRemovePaymentMethodResultFields + } + raw, err := filterVaultJSON(json.RawMessage(response.RawJSON()), fields) + if err != nil { + return fmt.Errorf("invalid %s response; inspect the wallet before retrying", operation) + } + if output == "json" { + return printVaultJSON(raw) + } + if operation == "add_payment_method" { + result := response.AsAddPaymentMethodVaultItemOperationResult() + pterm.Success.Println("Started payment method enrollment") + printVaultPaymentMethods("kernel", []kernel.VaultPaymentMethod{result.PaymentMethod}) + pterm.Info.Println("share the single-use card_enrollment url only with the intended cardholder; it expires after 30 minutes. every call creates a separate link, so never retry automatically. observe completion with wallets payment-methods; an expired or failed enrollment does not affect other methods.") + return nil + } + result := response.AsRemovePaymentMethodVaultItemOperationResult() + pterm.Success.Printf("Removed payment method: %s\n", result.PaymentMethodID) + pterm.Info.Println("removal is not proof that an unresolved payment did not occur; reconcile the purchase separately.") + return nil +} + func printVaultPaymentMethods(provider string, methods []kernel.VaultPaymentMethod) { if len(methods) == 0 { pterm.Info.Println("no payment methods returned") return } - rows := pterm.TableData{{"payment method id", "provider", "type", "label", "brand", "last4", "default", "single-use eligible", "reasons"}} + rows := pterm.TableData{{"payment method id", "provider", "type", "label", "brand", "last4", "default", "status", "single-use eligible", "reasons", "status reason", "expires at"}} for _, m := range methods { capability := m.Capabilities.SingleUseCard eligible := "unknown" if capability.JSON.Eligible.Valid() { eligible = fmt.Sprint(capability.Eligible) } - rows = append(rows, []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), eligible, strings.Join(capability.Reasons, ", ")}) + expiresAt := "" + if m.JSON.ExpiresAt.Valid() && !m.ExpiresAt.IsZero() { + expiresAt = util.FormatLocal(m.ExpiresAt) + } + rows = append(rows, []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), string(m.Status), eligible, strings.Join(capability.Reasons, ", "), m.StatusReason, expiresAt}) } PrintTableNoPad(rows, true) + for _, method := range methods { + if method.Action.JSON.URL.Valid() && vaultDisplayURL(method.Action.URL) { + pterm.Printf("payment method %s %s url:\n%s\n", method.ID, method.Action.Name, method.Action.URL) + } + } if provider == "kernel" { - pterm.Info.Println("kernel cards use the wallet key, not a payment method id. " + kernelEligibilityHelp) + pterm.Info.Println("select payment_method_id explicitly for payment_request items and for card items when the wallet has no default card. a legacy default card may be selected by omitting it only from a card spec. " + kernelEligibilityHelp) + pterm.Info.Println("pending_enrollment needs the returned cardholder action. removal_pending means retry remove_payment_method later; recovery_required handling depends on status_reason.") } else { - pterm.Info.Println("select an id explicitly in the card --spec json: Link uses payment_method_id; AgentCard uses card_id (or omit it for cardholder selection). capabilities are advisory; missing means unknown, not ineligible.") + pterm.Info.Println("select an id explicitly in the purchase --spec json: Link uses payment_method_id; AgentCard cards use card_id (or omit it for cardholder selection). capabilities are advisory; missing means unknown, not ineligible.") } } diff --git a/cmd/vaults_payment_method_operations_test.go b/cmd/vaults_payment_method_operations_test.go new file mode 100644 index 00000000..118498f1 --- /dev/null +++ b/cmd/vaults_payment_method_operations_test.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const kernelWalletPaymentOperationsFixture = `{ + "id":"wallet-id","key":"cardholder","type":"wallet", + "spec":{"provider":"kernel"}, + "state":{"provider":"kernel","status":"connected"}, + "available_operations":[ + {"type":"add_payment_method","description":"Enroll another card"}, + {"type":"remove_payment_method","description":"Remove one enrolled card"} + ], + "available_expansions":[{"type":"payment_methods","description":"List cards"}] +}` + +func TestVaultAddPaymentMethodOperation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, kernelWalletPaymentOperationsFixture) + return + } + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"add_payment_method"}`, string(body)) + _, _ = io.WriteString(w, `{ + "type":"add_payment_method", + "payment_method":{ + "id":"card-2","provider":"kernel","type":"card","is_default":false, + "status":"pending_enrollment","status_reason":"awaiting_cardholder", + "expires_at":"2026-09-01T12:30:00Z", + "display":{},"capabilities":{}, + "action":{"name":"card_enrollment","url":"https://vault.example/enroll?state=opaque","capture_token":"secret-capture"}, + "provider_secret":"secret-provider" + } + }`) + }) + out, text, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "add_payment_method", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, posts) + for _, want := range []string{`"type": "add_payment_method"`, `"status": "pending_enrollment"`, `"card_enrollment"`, "https://vault.example/enroll?state=opaque"} { + assert.Contains(t, out, want) + } + assert.Empty(t, text) + assert.NotContains(t, out, "secret-capture") + assert.NotContains(t, out, "secret-provider") + + _, text, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "add_payment_method") + require.NoError(t, err) + for _, want := range []string{"Started payment method enrollment", "card-2", "pending_enrollment", "card_enrollment url", "single-use", "expires after 30 minutes", "separate link"} { + assert.Contains(t, text, want) + } + assert.NotContains(t, text, "secret-") + assert.Equal(t, 2, posts) +} + +func TestVaultRemovePaymentMethodOperation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, kernelWalletPaymentOperationsFixture) + return + } + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"remove_payment_method","payment_method_id":"card-2"}`, string(body)) + _, _ = io.WriteString(w, `{"type":"remove_payment_method","status":"removed","payment_method_id":"card-2","provider_secret":"secret-provider"}`) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "remove_payment_method", "--params", `{"payment_method_id":"card-2"}`, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, posts) + assert.Contains(t, out, `"status": "removed"`) + assert.Contains(t, out, `"payment_method_id": "card-2"`) + assert.NotContains(t, out, "secret-provider") +} + +func TestVaultPaymentMethodOperationValidation(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) + for _, tc := range []struct { + operation string + params string + want string + }{ + {"add_payment_method", `{}`, "takes no parameters"}, + {"remove_payment_method", "", "requires --params"}, + {"remove_payment_method", `{}`, "payment_method_id"}, + {"remove_payment_method", `{"payment_method_id":""}`, "payment_method_id"}, + {"remove_payment_method", `{"payment_method_id":"card-1","other":true}`, "only supported"}, + } { + args := []string{"vaults", "items", "invoke", "checkout", "cardholder", tc.operation} + if tc.params != "" { + args = append(args, "--params", tc.params) + } + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, tc.want) + } + assert.Zero(t, calls) +} + +func TestVaultPaymentMethodsExposeLifecycleWithoutCaptureToken(t *testing.T) { + fixture := strings.TrimSuffix(kernelWalletPaymentOperationsFixture, "}") + `, + "expanded":{"payment_methods":[{ + "id":"card-2","provider":"kernel","type":"card","is_default":false, + "status":"pending_enrollment","status_reason":"awaiting_cardholder", + "expires_at":"2026-09-01T12:30:00Z", + "display":{"brand":"visa","last4":"1234"},"capabilities":{}, + "action":{"name":"card_enrollment","url":"https://vault.example/enroll?state=opaque","capture_token":"secret-capture"} + }]}}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "payment_methods", r.URL.Query().Get("expand")) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder", "-o", "json") + require.NoError(t, err) + for _, want := range []string{`"status": "pending_enrollment"`, `"status_reason": "awaiting_cardholder"`, `"expires_at"`, `"name": "card_enrollment"`, "https://vault.example/enroll?state=opaque"} { + assert.Contains(t, out, want) + } + assert.NotContains(t, out, "secret-capture") + + _, text, err := executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder") + require.NoError(t, err) + for _, want := range []string{"pending_enrollment", "awaiting_cardholder", "card_enrollment url", "payment_method_id explicitly", "removal_pending"} { + assert.Contains(t, text, want) + } + assert.NotContains(t, text, "secret-capture") +} + +func TestVaultPaymentMethodOperationHelp(t *testing.T) { + invoke, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) + require.NoError(t, err) + flat := strings.Join(strings.Fields(invoke.Long), " ") + for _, want := range []string{"add_payment_method takes no parameters", "remove_payment_method requires", "payment_method_id", "expires after 30 minutes", "separate pending enrollment", "without disconnecting the wallet", "removal_pending", "not proof that a payment did not occur", "vaults:write"} { + assert.Contains(t, flat, want) + } + assert.Contains(t, invoke.Example, "add_payment_method") + assert.Contains(t, invoke.Example, "remove_payment_method") + assert.Contains(t, invoke.Flags().Lookup("params").Usage, "remove_payment_method") +} diff --git a/cmd/vaults_payment_request_test.go b/cmd/vaults_payment_request_test.go new file mode 100644 index 00000000..8ea51c60 --- /dev/null +++ b/cmd/vaults_payment_request_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const linkPaymentRequestFixture = `{ + "id":"payment-request-id","key":"order-1","type":"payment_request", + "spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","context":"Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose."}, + "state":{"provider":"link","status":"requested"}, + "available_operations":[{"type":"authorize","description":"Authorize this purchase"}], + "available_expansions":[] +}` + +const kernelPaymentRequestFixture = `{ + "id":"payment-request-id","key":"order-1","type":"payment_request", + "spec":{"provider":"kernel","wallet":"cardholder","payment_method_id":"card-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}, + "state":{"provider":"kernel","status":"requested","funding_status":"available","payment_method_id":"card-1","masks":{"brand":"visa","last4":"1234","token_last4":"5678"}}, + "available_operations":[{"type":"authorize","description":"Authorize this purchase"}], + "available_expansions":[] +}` + +func TestVaultPaymentRequestCreateAndUpdate(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, operation := range []string{"create", "update"} { + for _, provider := range []string{"link", "kernel"} { + t.Run(operation+"/"+provider, func(t *testing.T) { + fixture := linkPaymentRequestFixture + spec := `{"wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","context":"Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose.","metadata":{"reference":"order-1"}}` + if provider == "kernel" { + fixture = kernelPaymentRequestFixture + spec = `{"wallet":"cardholder","payment_method_id":"card-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}` + } + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + expectedMethod := http.MethodPut + if operation == "update" { + expectedMethod = http.MethodPatch + } + assert.Equal(t, expectedMethod, r.Method) + assert.Equal(t, "/vaults/checkout/items/order-1", r.URL.Path) + 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, "payment_request", body.Type) + assert.JSONEq(t, fmt.Sprintf("%q", provider), string(body.Spec["provider"])) + expectedMethodID := `"card-1"` + if provider == "link" { + expectedMethodID = `"pm-1"` + } + assert.JSONEq(t, expectedMethodID, string(body.Spec["payment_method_id"])) + if provider == "link" { + assert.JSONEq(t, `{"reference":"order-1"}`, string(body.Spec["metadata"])) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "payment-requests", operation, "checkout", "order-1", "--provider", provider, "--spec", spec, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.Contains(t, out, `"type": "payment_request"`) + assert.Contains(t, out, `"payment_method_id"`) + }) + } + } +} + +func TestVaultPaymentRequestRejectsAgentCard(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) + _, _, err := executeVaultCommand(t, client, "vaults", "payment-requests", "create", "checkout", "order-1", "--provider", "agentcard", "--spec", `{}`) + require.ErrorContains(t, err, "support only link or kernel") + assert.Zero(t, calls) +} + +func TestVaultPaymentRequestOutputAndLinkAuthorize(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, linkPaymentRequestFixture) + return + } + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"authorize","browser_id":"browser-1","page_url":"https://shop.example/checkout"}`, string(body)) + _, _ = io.WriteString(w, strings.Replace(linkPaymentRequestFixture, `"status":"requested"`, `"status":"pending_authorization"`, 1)) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + for _, want := range []string{"payment_request", "payment method id", "pm-1", "wallet-1", "authorize"} { + assert.Contains(t, out+human, want) + } + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 3, calls) +} + +func TestVaultKernelPaymentRequestFundingOutput(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, kernelPaymentRequestFixture) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"funding_status": "available"`) + assert.Contains(t, out, `"payment_method_id": "card-1"`) + _, human, err = executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + for _, want := range []string{"funding status", "available", "card-1", "network token last4", "5678", "funding_status is separate from purchase status", "removed means the method is gone"} { + assert.Contains(t, human, want) + } +} + +func TestVaultPaymentRequestHelpAndFlags(t *testing.T) { + for _, path := range [][]string{{"payment-requests", "create"}, {"payment-requests", "update"}} { + cmd, _, err := newVaultsCommand().Find(path) + require.NoError(t, err) + assert.NotNil(t, cmd.Flags().Lookup("provider")) + assert.NotNil(t, cmd.Flags().Lookup("spec")) + for _, want := range []string{"payment_request", "LinkPaymentRequestSpec", "KernelPaymentRequestSpec", "payment_method_id", "payment-requests", "AgentCard reusable", "funding_status is separate from purchase status"} { + assert.Contains(t, cmd.Long, want) + } + assert.Contains(t, cmd.Example, `"payment_method_id"`) + } +} diff --git a/cmd/vaults_webmcp.go b/cmd/vaults_webmcp.go index 7cca5641..cd0d438c 100644 --- a/cmd/vaults_webmcp.go +++ b/cmd/vaults_webmcp.go @@ -11,7 +11,6 @@ import ( "strings" kernel "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -220,7 +219,7 @@ func vaultWebMCPNullSlot(input any, path string) bool { } func (c VaultsCmd) webMCPInvoke(ctx context.Context, vault, key string, request *kernel.WebmcpInvokeVaultItemOperationRequestParam, output string) error { - response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfWebmcpInvoke: request}, option.WithMaxRetries(0)) + response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfWebmcpInvoke: request}, vaultWriteRequestOptions()...) if err != nil { return vaultOperationRequestError(err, "webmcp_invoke", vaultWebMCPErrorMessages, vaultWebMCPRejected, vaultWebMCPUncertain) } @@ -281,7 +280,7 @@ func newVaultWebMCPCommand() *cobra.Command { Use: "invoke ", Short: "Invoke a live WebMCP tool with vault fields substituted into null input slots", Args: cobra.ExactArgs(2), - Long: `Invoke a live WebMCP tool with values from a credential item or ready Link card. + Long: `Invoke a live WebMCP tool with values from a credential item or ready Link card/payment_request item. This sends the advertised webmcp_invoke operation; it is equivalent to items invoke webmcp_invoke --spec-file with the same parameters. diff --git a/go.mod b/go.mod index 50e81ce4..f107f70f 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.132.0 + github.com/kernel/kernel-go-sdk v0.133.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 @@ -41,6 +41,7 @@ require ( github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.5.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect diff --git a/go.sum b/go.sum index 411ca31d..cdb3a6eb 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= @@ -66,8 +68,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.132.0 h1:LWwIuUqQ2fYu0oLUE9a1G7Yi0Id1Yb32bVIFBdIqQV0= -github.com/kernel/kernel-go-sdk v0.132.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.133.0 h1:rDA5AQ/rFKHCbyerGYmGMOQ9uPv7N415rzeSsgQhsL8= +github.com/kernel/kernel-go-sdk v0.133.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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 4ab2d4f1239b941aef047da811f348c5a7bccf38 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:53:21 +0000 Subject: [PATCH 3/8] Report create vs overwrite and disable retries for browser saves save-profile now prints whether it created or overwrote the profile, and no longer lets the SDK retry: a retry after a failed or slow save starts a second save and restarts the browser again. The help text names browser_not_active, mentions Playwright connections, and says to save at a stable point. --- cmd/browsers.go | 33 ++++++++++++++++++++++++++------- cmd/browsers_test.go | 9 ++++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 8a2a9d4f..4b2cd7bd 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -1106,7 +1106,10 @@ func (b BrowsersCmd) SaveProfile(ctx context.Context, in BrowsersSaveProfileInpu } else { destination.Name = kernel.Opt(in.ProfileName) } - profile, err := b.browsers.SaveProfile(ctx, in.Identifier, kernel.BrowserSaveProfileParams{SaveProfileRequest: destination}) + // A retry after a failed or timed-out save would start a second save and + // restart the browser again. + var res *http.Response + profile, err := b.browsers.SaveProfile(ctx, in.Identifier, kernel.BrowserSaveProfileParams{SaveProfileRequest: destination}, option.WithMaxRetries(0), option.WithResponseInto(&res)) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -1126,10 +1129,23 @@ func (b BrowsersCmd) SaveProfile(ctx context.Context, in BrowsersSaveProfileInpu {"Updated At", util.FormatLocal(profile.UpdatedAt)}, {"Last Used At", util.FormatLocal(profile.LastUsedAt)}, }, true) + pterm.Success.Println(saveProfileOutcome(res)) pterm.Info.Println("The browser restarted. Reconnect CDP clients; WebDriver clients must create a new session.") return nil } +// saveProfileOutcome reports whether the save created the profile (201) or +// overwrote an existing one (200). +func saveProfileOutcome(res *http.Response) string { + if res != nil && res.StatusCode == http.StatusCreated { + return "Created profile" + } + if res != nil && res.StatusCode == http.StatusOK { + return "Overwrote profile" + } + return "Saved profile" +} + // browserUpdateProxySelection folds every proxy flag `browsers update` accepts // into the single proxy configuration the API takes. --clear-proxy drops a // selected proxy back to the browser default, and --disable-default-proxy @@ -3056,9 +3072,10 @@ var browsersSaveProfileCmd = &cobra.Command{ Provide exactly one destination: --profile-id overwrites an existing profile; --profile-name overwrites a matching profile in the browser's project or creates it. -Saving restarts the browser without ending the session. Active CDP and WebDriver -connections close; reconnect CDP clients and create a new WebDriver session. Tabs are -restored, but pages reload and in-memory page state is lost. The command returns after +Saving restarts the browser without ending the session. Active CDP (including Playwright) +and WebDriver connections close; reconnect CDP clients and create a new WebDriver session. +Tabs are restored, but pages reload and in-memory page state is lost, so save at a stable +point, such as right after a login completes. The command returns after the restarted browser accepts CDP connections and the profile upload completes. Caches, Service Worker storage, and Google Drive IndexedDB data are excluded from the saved profile but remain in the running browser. @@ -3073,9 +3090,11 @@ started save wins. An automatic end-of-session save from save_changes is instead ordered against these saves by completion time. A successful save restarts the browser inactivity timeout; a failed save leaves it unchanged. -A 409 can mean the browser is inactive, a pooled browser was released or reacquired -(browser_lease_changed), or it is briefly busy (browser_busy). Retry only browser_busy, -or resolve the current pooled-browser lease before making a new explicit save.`, +A 409 can mean the browser is no longer running (browser_not_active), a pooled browser +was released or reacquired (browser_lease_changed), or it is briefly busy (browser_busy). +Retry only browser_busy, or resolve the current pooled-browser lease before making a new +explicit save. The command doesn't retry on its own; if it is interrupted, the save may +still finish, so check the profile before saving again.`, Args: cobra.ExactArgs(1), RunE: runBrowsersSaveProfile, } diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 799a8dd2..ed3abefa 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -148,6 +148,7 @@ func TestBrowsersSaveProfile(t *testing.T) { fake := &FakeBrowsersService{SaveProfileFunc: func(ctx context.Context, id string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (*kernel.Profile, error) { calls++ assert.Equal(t, "browser-1", id) + assert.Len(t, opts, 2, "save-profile disables retries and captures the response") raw, err := json.Marshal(body) require.NoError(t, err) assert.JSONEq(t, tc.expected, string(raw)) @@ -181,11 +182,17 @@ func TestBrowsersSaveProfileValidationAndHelp(t *testing.T) { require.NoError(t, err) assert.NotNil(t, cmd.Flags().Lookup("profile-id")) assert.NotNil(t, cmd.Flags().Lookup("profile-name")) - for _, text := range []string{"Saving restarts the browser", "CDP", "WebDriver", "pages reload", "profile upload completes", "remain in the running browser", "does not attach", "save_changes", "refresh_on_profile_update", "completion time", "failed save leaves it unchanged", "browser_busy", "browser_lease_changed"} { + for _, text := range []string{"Saving restarts the browser", "CDP", "Playwright", "WebDriver", "pages reload", "stable", "profile upload completes", "remain in the running browser", "does not attach", "save_changes", "refresh_on_profile_update", "completion time", "failed save leaves it unchanged", "browser_busy", "browser_lease_changed", "browser_not_active", "doesn't retry"} { assert.Contains(t, cmd.Long, text) } } +func TestSaveProfileOutcome(t *testing.T) { + assert.Equal(t, "Created profile", saveProfileOutcome(&http.Response{StatusCode: http.StatusCreated})) + assert.Equal(t, "Overwrote profile", saveProfileOutcome(&http.Response{StatusCode: http.StatusOK})) + assert.Equal(t, "Saved profile", saveProfileOutcome(nil)) +} + func TestBrowsersCurlRawUsesBrowserHTTPClient(t *testing.T) { var ( gotMethod string From e4f0bfe03d771501eb9cc48a9eb986562dcbf3fe Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:02:09 +0000 Subject: [PATCH 4/8] Update Go SDK to b65fda9ebe34d4ae3b609d7434ab9c64d5156144 and CLI coverage --- cmd/api_keys.go | 6 ++++-- cmd/api_keys_test.go | 14 ++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/cmd/api_keys.go b/cmd/api_keys.go index e4bae873..019cfcea 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -152,8 +152,10 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { params.SortBy = kernel.APIKeyListParamsSortByName case "expires_at": params.SortBy = kernel.APIKeyListParamsSortByExpiresAt + case "last_used_at": + params.SortBy = kernel.APIKeyListParamsSortByLastUsedAt default: - return fmt.Errorf("invalid --sort-by value: %s (must be 'created_at', 'name', or 'expires_at')", in.SortBy) + return fmt.Errorf("invalid --sort-by value: %s (must be 'created_at', 'name', 'expires_at', or 'last_used_at')", in.SortBy) } } if in.SortDirection != "" { @@ -555,7 +557,7 @@ func init() { apiKeysListCmd.Flags().String("query", "", "Search API keys by name, creator, or project (identifiers and masked keys match by exact value or prefix)") apiKeysListCmd.Flags().String("status", "", "Filter by status: 'active' (default), 'deleted', or 'all'") apiKeysListCmd.Flags().Bool("include-deleted", false, "Deprecated: Use --status all instead. Include soft-deleted API keys in the results") - apiKeysListCmd.Flags().String("sort-by", "", "Sort by: created_at, name, or expires_at") + apiKeysListCmd.Flags().String("sort-by", "", "Sort by: created_at, name, expires_at, or last_used_at. Keys without an expiry sort last for expires_at; keys without recorded use sort last for last_used_at, in either direction") apiKeysListCmd.Flags().String("sort-direction", "", "Sort direction: asc or desc") addJSONOutputFlag(apiKeysGetCmd) diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 9f1fb35d..9e1ae858 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -207,6 +207,20 @@ func TestAPIKeysListPassesPaginationAndRendersRows(t *testing.T) { assert.Contains(t, out, util.FormatLocal(key.LastUsedAt)) } +func TestAPIKeysListSupportsLastUsedAtSort(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAPIKeysService{ + ListFunc: func(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) { + assert.Equal(t, kernel.APIKeyListParamsSortByLastUsedAt, query.SortBy) + return &pagination.OffsetPagination[kernel.APIKey]{Items: []kernel.APIKey{}}, nil + }, + } + c := APIKeysCmd{apiKeys: fake} + + err := c.List(context.Background(), APIKeysListInput{SortBy: "last_used_at"}) + require.NoError(t, err) +} + func TestFormatAPIKeyLastUsedAtHandlesUnrecordedUse(t *testing.T) { key := apiKeyFromJSON(`{"last_used_at":null}`) assert.Equal(t, "-", formatAPIKeyLastUsedAt(*key)) diff --git a/go.mod b/go.mod index f107f70f..adc99120 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.133.0 + github.com/kernel/kernel-go-sdk v0.134.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 cdb3a6eb..df093359 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,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.133.0 h1:rDA5AQ/rFKHCbyerGYmGMOQ9uPv7N415rzeSsgQhsL8= -github.com/kernel/kernel-go-sdk v0.133.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= +github.com/kernel/kernel-go-sdk v0.134.0 h1:cdDL91I1IS8t85BMAlle2qBAWu0+f5lXt4nUtyhL4E0= +github.com/kernel/kernel-go-sdk v0.134.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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 02bfb06ca2cf11ee2745e8c9575b7c1d848a862a Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:02:47 +0000 Subject: [PATCH 5/8] Update Go SDK to 7ae0d1549fa45a8a87c1445b57d7a5884c0b5045 and CLI coverage --- cmd/vaults_credentials.go | 20 +++++++++++++++++--- cmd/vaults_onepassword_resolved_test.go | 1 + cmd/vaults_onepassword_test.go | 11 +++++++++++ go.mod | 2 +- go.sum | 4 ++-- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index e4246f29..287a4548 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -82,8 +82,18 @@ credentials connect again with the same key for a new link.` const vaultOnePasswordStoredTokenHelp = `stored-token 1Password credentials (developer integrations only, separate from the flow above): a developer who already holds a 1Password broker access token and its matching integration key may create the credential with access_token, integration_key, -and requests instead of account. supply either account or both secrets, never both. put them only in a protected --spec-file -or stdin; they are write-only and never displayed. never ask an end user for them. +and requests instead of account. supply either account or both secrets, never both. put +them only in a protected --spec-file or stdin; they are write-only and never displayed. +never ask an end user for them. +for a new approval, omit access_request_id, resolved, and request entry ids, then use +the access-request operations above. if the developer already created an access request +with the same token and integration key and the user approved it, supply access_request_id, 1-5 resolved +entries from the 1Password SDK or extension, and an id on every requests entry. +access_request_id and every entry id must be non-empty and at most 128 characters. +copy each resolved reference unchanged and include its entryId to identify the request +entry it satisfies. entryId may be omitted only when requests has one entry. +the item starts ready and KERNEL does not create another access request. +KERNEL does not verify these references; 1Password checks them during 1pw_fill. 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 @@ -194,7 +204,11 @@ 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)") + specFileUsage := "credential spec json file (use '-' for stdin; maximum 128 KiB)" + if !update { + specFileUsage = "credential spec json file (supports pre-approved 1Password access_request_id/resolved; use '-' for stdin; maximum 128 KiB)" + } + cmd.Flags().String("spec-file", "", specFileUsage) _ = cmd.MarkFlagRequired("spec-file") cmd.Flags().Bool("open", false, "open the returned https collection url") addVaultJSONOutputFlag(cmd) diff --git a/cmd/vaults_onepassword_resolved_test.go b/cmd/vaults_onepassword_resolved_test.go index 6b84e8c4..ba54ac82 100644 --- a/cmd/vaults_onepassword_resolved_test.go +++ b/cmd/vaults_onepassword_resolved_test.go @@ -23,6 +23,7 @@ func TestVaultOnePasswordResolvedReferencesAreDisplayed(t *testing.T) { }) out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "github", "-o", "json") require.NoError(t, err) + assert.Contains(t, out, `"id": "entry-1"`) assert.Contains(t, out, `"resolved"`) assert.Contains(t, out, `"reference": "op://Private/GitHub/login"`) assert.Contains(t, out, `"entryId": "entry-1"`) diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index 22f880e9..5423f9e9 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -50,10 +50,12 @@ func TestCredentialConnectOnePassword(t *testing.T) { func TestCredentialCreateOnePassword(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") requests := `"requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"},"reason":"Work account"},{"type":"login","parameters":{"website":"https://github.com"},"reason":"Personal account"}]}` + preResolved := `{"provider":"1password","access_token":"token-secret","integration_key":"key-secret","access_request_id":"request-approved","requests":{"version":2,"entries":[{"id":"entry-work","type":"login","parameters":{"website":"https://github.com"}},{"id":"entry-personal","type":"login","parameters":{"website":"https://github.com"}}]},"resolved":[{"reference":"op://Private/GitHub Work/login","entryId":"entry-work"},{"reference":"op://Private/GitHub Personal/login","entryId":"entry-personal"}]}` entry := `{"type":"login","parameters":{"website":"https://github.com"}}` for _, spec := range []string{ `{"provider":"1password","account":"onepassword",` + requests + `}`, `{"provider":"1password","access_token":"token-secret","integration_key":"key-secret",` + requests + `}`, + preResolved, } { calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { @@ -295,6 +297,15 @@ func TestCredentialHelpPresentsBothPaths(t *testing.T) { } assert.Contains(t, credentials.Long, "stored-token 1Password credentials (developer integrations only") assert.Contains(t, credentials.Long, "never ask an end user for them") + assert.Contains(t, credentials.Long, "omit access_request_id, resolved, and request entry ids") + assert.Contains(t, credentials.Long, "supply access_request_id, 1-5 resolved") + assert.Contains(t, credentials.Long, "an id on every requests entry") + assert.Contains(t, credentials.Long, "access_request_id and every entry id must be non-empty and at most 128 characters") + assert.Contains(t, credentials.Long, "entryId may be omitted only when requests has one entry") + assert.Contains(t, credentials.Long, "item starts ready and KERNEL does not create another access request") + assert.Contains(t, credentials.Long, "1Password checks them during 1pw_fill") + require.NotNil(t, credentials.Flag("spec-file")) + assert.Contains(t, credentials.Flag("spec-file").Usage, "access_request_id/resolved") assert.NotContains(t, connect.Long, "stored-token") assert.Contains(t, connect.Long, "credential_account") assert.Contains(t, credentials.Example, `"provider":"1password","account":"onepassword"`) diff --git a/go.mod b/go.mod index adc99120..a0939614 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.134.0 + github.com/kernel/kernel-go-sdk v0.135.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 df093359..96017114 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,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.134.0 h1:cdDL91I1IS8t85BMAlle2qBAWu0+f5lXt4nUtyhL4E0= -github.com/kernel/kernel-go-sdk v0.134.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= +github.com/kernel/kernel-go-sdk v0.135.0 h1:wOID4GW1Hb73wtkEZpEYOfYInWTCQgCZ9eRpidEytk8= +github.com/kernel/kernel-go-sdk v0.135.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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 d5e9abe3f31a7a6b3db1ee08e4a202dd72f8fcc0 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:35:19 +0000 Subject: [PATCH 6/8] Update Go SDK to 66f4c3396eb162eacbbc7b614e89cfc7e6a01585 and CLI coverage --- cmd/api_keys.go | 60 +++++++++++- cmd/api_keys_test.go | 57 ++++++++++++ cmd/browser_pools.go | 39 +++++++- cmd/browser_pools_test.go | 36 ++++++++ cmd/browsers.go | 122 ++++++++++++++++++------- cmd/browsers_test.go | 64 ++++++++++++- cmd/vaults.go | 28 ++++-- cmd/vaults_commands.go | 96 ++++++++++--------- cmd/vaults_confirm_transaction.go | 91 ++++++++++++++---- cmd/vaults_confirm_transaction_test.go | 63 +++++++++++++ cmd/vaults_help.go | 63 ++++++++++++- cmd/vaults_invoke_test.go | 5 +- cmd/vaults_link.go | 26 ++++++ cmd/vaults_operation_params.go | 2 +- cmd/vaults_output.go | 18 +++- cmd/vaults_payment_help_test.go | 12 +-- cmd/vaults_shop_pay_test.go | 122 +++++++++++++++++++++++++ cmd/vaults_spec_test.go | 3 +- cmd/vaults_wallet_spec.go | 22 +++++ go.mod | 2 +- go.sum | 4 +- 21 files changed, 809 insertions(+), 126 deletions(-) create mode 100644 cmd/vaults_shop_pay_test.go diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 019cfcea..a4eb39f3 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -2,7 +2,10 @@ package cmd import ( "context" + "encoding/json" "fmt" + "strings" + "time" "github.com/kernel/cli/pkg/interactive" "github.com/kernel/cli/pkg/util" @@ -30,7 +33,9 @@ type APIKeysCmd struct { type APIKeysCreateInput struct { Name string DaysToExpire Int64Flag + ExpiresAt string ProjectID string + Policy string Output string } @@ -71,6 +76,29 @@ type APIKeysDeleteInput struct { SkipConfirm bool } +func parseAPIKeyPolicy(raw string) (kernel.APIKeyNewParamsPolicy, bool, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return kernel.APIKeyNewParamsPolicy{}, false, nil + } + if !strings.HasPrefix(trimmed, "{") { + return kernel.APIKeyNewParamsPolicy{}, false, fmt.Errorf("--policy must be a JSON object with a statements array") + } + var policy kernel.APIKeyNewParamsPolicy + if err := json.Unmarshal([]byte(trimmed), &policy); err != nil { + return kernel.APIKeyNewParamsPolicy{}, false, fmt.Errorf("--policy must be a JSON object with a statements array: %w", err) + } + if len(policy.Statements) < 1 || len(policy.Statements) > 256 { + return kernel.APIKeyNewParamsPolicy{}, false, fmt.Errorf("--policy statements must contain between 1 and 256 entries") + } + for i, statement := range policy.Statements { + if strings.TrimSpace(statement.Action) == "" || strings.TrimSpace(statement.Resource) == "" { + return kernel.APIKeyNewParamsPolicy{}, false, fmt.Errorf("--policy statements[%d] requires non-empty action and resource", i) + } + } + return policy, true, nil +} + func (c APIKeysCmd) Create(ctx context.Context, in APIKeysCreateInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -79,6 +107,10 @@ func (c APIKeysCmd) Create(ctx context.Context, in APIKeysCreateInput) error { return fmt.Errorf("--name is required") } + if in.DaysToExpire.Set && in.ExpiresAt != "" { + return fmt.Errorf("--days-to-expire and --expires-at are mutually exclusive") + } + params := kernel.APIKeyNewParams{Name: in.Name} if in.DaysToExpire.Set { if in.DaysToExpire.Value < 1 || in.DaysToExpire.Value > 3650 { @@ -86,9 +118,26 @@ func (c APIKeysCmd) Create(ctx context.Context, in APIKeysCreateInput) error { } params.DaysToExpire = kernel.Int(in.DaysToExpire.Value) } + if in.ExpiresAt != "" { + expiresAt, err := time.Parse(time.RFC3339, in.ExpiresAt) + if err != nil { + return fmt.Errorf("--expires-at must be an RFC3339 timestamp") + } + if !expiresAt.After(time.Now()) { + return fmt.Errorf("--expires-at must be in the future") + } + params.ExpiresAt = kernel.Opt(expiresAt) + } if in.ProjectID != "" { params.ProjectID = kernel.String(in.ProjectID) } + policy, policySet, err := parseAPIKeyPolicy(in.Policy) + if err != nil { + return err + } + if policySet { + params.Policy = policy + } key, err := c.apiKeys.New(ctx, params) if err != nil { @@ -415,7 +464,9 @@ func runAPIKeysCreate(cmd *cobra.Command, args []string) error { c := getAPIKeysHandler(cmd) name, _ := cmd.Flags().GetString("name") daysToExpire, _ := cmd.Flags().GetInt64("days-to-expire") + expiresAt, _ := cmd.Flags().GetString("expires-at") projectID, _ := cmd.Flags().GetString("project-id") + policy, _ := cmd.Flags().GetString("policy") output, _ := cmd.Flags().GetString("output") return c.Create(cmd.Context(), APIKeysCreateInput{ @@ -424,7 +475,9 @@ func runAPIKeysCreate(cmd *cobra.Command, args []string) error { Set: cmd.Flags().Changed("days-to-expire"), Value: daysToExpire, }, + ExpiresAt: expiresAt, ProjectID: projectID, + Policy: policy, Output: output, }) } @@ -500,7 +553,7 @@ var apiKeysCmd = &cobra.Command{ var apiKeysCreateCmd = &cobra.Command{ Use: "create", Short: "Create an API key", - Long: "Create an API key.\n\nBy default the new key is org-wide. Use --project-id to create a key whose own access is scoped to that project. The global --project flag only scopes this CLI request.", + Long: "Create an API key.\n\nBy default the new key is org-wide. Use --project-id to create a key whose own access is scoped to that project. The global --project flag only scopes this CLI request. Use --policy to issue a least-privilege key: every action and resource not listed in its allow statements is denied.", Args: cobra.NoArgs, RunE: runAPIKeysCreate, } @@ -546,8 +599,11 @@ var apiKeysDeleteCmd = &cobra.Command{ func init() { addJSONOutputFlag(apiKeysCreateCmd) apiKeysCreateCmd.Flags().String("name", "", "API key name (required)") - apiKeysCreateCmd.Flags().Int64("days-to-expire", 0, "Number of days until expiry (1-3650); omit for never") + apiKeysCreateCmd.Flags().Int64("days-to-expire", 0, "Number of days until expiry (1-3650); omit for never; mutually exclusive with --expires-at") + apiKeysCreateCmd.Flags().String("expires-at", "", "Exact future expiry time in RFC3339 format; cannot be later than the issuing credential's expiry; mutually exclusive with --days-to-expire") apiKeysCreateCmd.Flags().String("project-id", "", "Create a project-scoped API key for this project ID; omit for org-wide") + apiKeysCreateCmd.Flags().String("policy", "", `JSON policy object, for example {"statements":[{"action":"browsers:use","resource":"projects/proj_123/browsers/*"}]}; unlisted access is denied`) + apiKeysCreateCmd.MarkFlagsMutuallyExclusive("days-to-expire", "expires-at") _ = apiKeysCreateCmd.MarkFlagRequired("name") addJSONOutputFlag(apiKeysListCmd) diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 9e1ae858..9427d874 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "testing" + "time" "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" @@ -113,6 +114,62 @@ func TestAPIKeysCreateBuildsParamsAndPrintsPlaintextKey(t *testing.T) { assert.Contains(t, out, "Prod") } +func TestAPIKeysCreateSupportsExactExpiryAndPolicy(t *testing.T) { + capturePtermOutput(t) + expiresAt := "2099-01-15T09:00:00Z" + fake := &FakeAPIKeysService{ + NewFunc: func(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) { + expectedExpiry, err := time.Parse(time.RFC3339, expiresAt) + require.NoError(t, err) + assert.True(t, body.ExpiresAt.Valid()) + assert.Equal(t, expectedExpiry, body.ExpiresAt.Value) + require.Len(t, body.Policy.Statements, 2) + assert.Equal(t, "browsers:use", body.Policy.Statements[0].Action) + assert.Equal(t, "projects/proj_123/browsers/*", body.Policy.Statements[0].Resource) + assert.Equal(t, "vaults:read", body.Policy.Statements[1].Action) + return createdAPIKeyFromJSON(`{"id":"key_123","name":"ci","key":"sk_live_123","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123"},"expires_at":"2099-01-15T09:00:00Z","project_id":"proj_123"}`), nil + }, + } + c := APIKeysCmd{apiKeys: fake} + + err := c.Create(context.Background(), APIKeysCreateInput{ + Name: "ci", + ExpiresAt: expiresAt, + ProjectID: "proj_123", + Policy: `{"statements":[{"action":"browsers:use","resource":"projects/proj_123/browsers/*"},{"action":"vaults:read","resource":"projects/proj_123/vaults/*"}]}`, + }) + require.NoError(t, err) +} + +func TestAPIKeysCreateRejectsInvalidExpiryAndPolicy(t *testing.T) { + called := false + fake := &FakeAPIKeysService{NewFunc: func(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) { + called = true + return nil, nil + }} + c := APIKeysCmd{apiKeys: fake} + tests := []struct { + name string + input APIKeysCreateInput + wantErr string + }{ + {"both expiry forms", APIKeysCreateInput{Name: "ci", DaysToExpire: Int64Flag{Set: true, Value: 30}, ExpiresAt: "2099-01-15T09:00:00Z"}, "mutually exclusive"}, + {"invalid expiry", APIKeysCreateInput{Name: "ci", ExpiresAt: "tomorrow"}, "RFC3339"}, + {"past expiry", APIKeysCreateInput{Name: "ci", ExpiresAt: "2020-01-01T00:00:00Z"}, "future"}, + {"invalid policy json", APIKeysCreateInput{Name: "ci", Policy: "[]"}, "JSON object"}, + {"empty statements", APIKeysCreateInput{Name: "ci", Policy: `{"statements":[]}`}, "between 1 and 256"}, + {"empty statement field", APIKeysCreateInput{Name: "ci", Policy: `{"statements":[{"action":"","resource":"projects/*"}]}`}, "non-empty action and resource"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + called = false + err := c.Create(context.Background(), tt.input) + require.ErrorContains(t, err, tt.wantErr) + assert.False(t, called) + }) + } +} + func TestAPIKeysCreateRejectsInvalidDaysToExpire(t *testing.T) { c := APIKeysCmd{apiKeys: &FakeAPIKeysService{}} diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 32167862..9ddce44b 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -10,6 +10,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/pterm/pterm" "github.com/spf13/cobra" ) @@ -387,6 +388,8 @@ type BrowserPoolsUpdateInput struct { AllowedHosts []string AllowedHostsProvided bool ClearAllowedHosts bool + ExpectedNetwork string + ExpectedNetworkSet bool Viewport string ChromePolicy string ChromePolicyFile string @@ -397,6 +400,20 @@ type BrowserPoolsUpdateInput struct { Output string } +func parseExpectedPoolNetwork(raw string, set bool) (kernel.BrowserNetworkConfigParam, bool, error) { + if !set { + return kernel.BrowserNetworkConfigParam{}, false, nil + } + if strings.TrimSpace(raw) == "" { + return kernel.BrowserNetworkConfigParam{}, false, fmt.Errorf("--expected-network requires a JSON object; use '{}' when the last pool response had no network configuration") + } + var object map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &object); err != nil || object == nil { + return kernel.BrowserNetworkConfigParam{}, false, fmt.Errorf("--expected-network must be the network JSON object from the last pool response") + } + return param.Override[kernel.BrowserNetworkConfigParam](json.RawMessage(raw)), true, nil +} + func validateBrowserPoolUpdateInput(in BrowserPoolsUpdateInput) error { if in.StartURL != "" && in.ClearStartURL { return fmt.Errorf("cannot specify both --start-url and --clear-start-url") @@ -448,6 +465,13 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) } params := kernel.BrowserPoolUpdateParams{} + expectedNetwork, expectedNetworkSet, err := parseExpectedPoolNetwork(in.ExpectedNetwork, in.ExpectedNetworkSet) + if err != nil { + return err + } + if expectedNetworkSet { + params.ExpectedNetwork = expectedNetwork + } if in.Name != "" { params.Name = kernel.String(in.Name) @@ -820,8 +844,15 @@ var browserPoolsGetCmd = &cobra.Command{ var browserPoolsUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update a browser pool", - Args: cobra.ExactArgs(1), - RunE: runBrowserPoolsUpdate, + Long: `Update a browser pool. Omitted fields leave existing values unchanged. + +Network flags replace the pool's whole network object. Use --expected-network with the +exact network object from the last 'browser-pools get -o json' response (or '{}' when it +had none) to make the entire update conditional. A changed saved network returns 409 +stale_network without applying any field. Explicit empty lists differ from omitted lists, +and proxy-route targets must use the resolved IDs returned by the API, not equivalent names.`, + Args: cobra.ExactArgs(1), + RunE: runBrowserPoolsUpdate, } var browserPoolsDeleteCmd = &cobra.Command{ @@ -913,6 +944,7 @@ func init() { browserPoolsUpdateCmd.Flags().Bool("clear-proxy-routes", false, "Remove the pool's proxy routes (combine with --private-host or --allowed-host to keep those settings; an omitted --allowed-host removes the pool's allowlist)") browserPoolsUpdateCmd.Flags().StringSlice("allowed-host", nil, "Set or replace the pool's egress allowlist (repeat or comma-separated, max 100), using the same entry rules as 'browsers create --allowed-host'. An empty list is invalid. Changing the allowlist automatically replaces idle browsers; leased browsers keep theirs until release, when it is reset to the new list or the browser is replaced. Network flags replace the pool's whole network configuration, so pass --private-host and --proxy-route too to keep them. Requires proxy v3") browserPoolsUpdateCmd.Flags().Bool("clear-allowed-hosts", false, "Remove the pool's egress allowlist and replace idle browsers (combine with --private-host or --proxy-route to keep those settings)") + browserPoolsUpdateCmd.Flags().String("expected-network", "", "Optimistic concurrency precondition: exact network JSON object from the last pool response, or '{}' when it had none. Omit for an unconditional update") browserPoolsUpdateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") browserPoolsUpdateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object") browserPoolsUpdateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)") @@ -1068,6 +1100,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { clearProxyRoutes, _ := cmd.Flags().GetBool("clear-proxy-routes") allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") clearAllowedHosts, _ := cmd.Flags().GetBool("clear-allowed-hosts") + expectedNetwork, _ := cmd.Flags().GetString("expected-network") viewport, _ := cmd.Flags().GetString("viewport") chromePolicy, _ := cmd.Flags().GetString("chrome-policy") chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") @@ -1104,6 +1137,8 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { AllowedHosts: allowedHosts, AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), ClearAllowedHosts: clearAllowedHosts, + ExpectedNetwork: expectedNetwork, + ExpectedNetworkSet: cmd.Flags().Changed("expected-network"), Viewport: viewport, ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index 2b48c059..e942eddc 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -371,6 +371,42 @@ func TestBrowserPoolsUpdate_WithRefreshOnProfileUpdate(t *testing.T) { assert.False(t, captured.RefreshOnProfileUpdate.Value) } +func TestBrowserPoolsUpdate_WithExpectedNetwork(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserPoolUpdateParams + fake := &FakeBrowserPoolsService{ + UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + captured = body + return &kernel.BrowserPool{ID: id}, nil + }, + } + + rawNetwork := `{"private_hosts":[],"allowed_hosts":["example.com"],"proxy_routes":[{"hosts":["api.example.com"],"proxy":{"id":"proxy-1"}}]}` + c := BrowserPoolsCmd{client: fake} + err := c.Update(context.Background(), BrowserPoolsUpdateInput{ + IDOrName: "pool-1", + Name: "new-name", + ExpectedNetwork: rawNetwork, + ExpectedNetworkSet: true, + }) + require.NoError(t, err) + body, err := json.Marshal(captured) + require.NoError(t, err) + assert.JSONEq(t, `{"name":"new-name","expected_network":`+rawNetwork+`}`, string(body)) + + for _, raw := range []string{"", "null", "[]", "not-json"} { + called := false + fake.UpdateFunc = func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + called = true + return &kernel.BrowserPool{ID: id}, nil + } + err := c.Update(context.Background(), BrowserPoolsUpdateInput{IDOrName: "pool-1", ExpectedNetwork: raw, ExpectedNetworkSet: true}) + require.ErrorContains(t, err, "--expected-network") + assert.False(t, called) + } +} + func TestBrowserPoolsCreate_WithChromePolicy(t *testing.T) { setupStdoutCapture(t) diff --git a/cmd/browsers.go b/cmd/browsers.go index 4b2cd7bd..f1ee05a6 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -222,6 +222,18 @@ func parseVideoMemoryFlag(videoMemory string) (kernel.BrowserVideoMemory, error) return "", fmt.Errorf("invalid --video-memory value: %s (must be one of %s)", videoMemory, strings.Join(availableVideoMemorySizes(), ", ")) } +// parseFingerprintOS validates the closed-beta browser fingerprint override. +// Omission preserves the browser's default fingerprint. +func parseFingerprintOS(os string) (kernel.BrowserFingerprintOs, error) { + if os == "" { + return "", nil + } + if os == string(kernel.BrowserFingerprintOsMacos) { + return kernel.BrowserFingerprintOsMacos, nil + } + return "", fmt.Errorf("invalid --fingerprint-os value: %s (must be macos)", os) +} + // maxPrivateHosts mirrors the API's cap on network.private_hosts entries. const maxPrivateHosts = 32 @@ -517,6 +529,7 @@ type BrowsersCreateInput struct { GPU BoolFlag Memory string VideoMemory string + FingerprintOS string InvocationID string Kiosk BoolFlag ProfileID string @@ -600,10 +613,12 @@ type BrowsersUpdateInput struct { ClearLocale bool // AllowedHosts replaces the session's egress allowlist when // AllowedHostsProvided is set; ClearAllowedHosts removes it. - AllowedHosts []string - AllowedHostsProvided bool - ClearAllowedHosts bool - Output string + AllowedHosts []string + AllowedHostsProvided bool + ClearAllowedHosts bool + ExpectedAllowlistRevision string + MutationID string + Output string } // BrowsersCmd is a cobra-independent command handler for browsers operations. @@ -779,6 +794,13 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if videoMemory != "" { params.VideoMemory = videoMemory } + fingerprintOS, err := parseFingerprintOS(in.FingerprintOS) + if err != nil { + return err + } + if fingerprintOS != "" { + params.Fingerprint = kernel.BrowserFingerprintParam{Os: fingerprintOS} + } if in.InvocationID != "" { params.InvocationID = kernel.Opt(in.InvocationID) } @@ -1075,6 +1097,12 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { } tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}) tableData = append(tableData, []string{"Allowed Hosts", formatAllowedHosts(browser.Network)}) + if browser.Network.AllowlistState.JSON.Revision.Valid() { + tableData = append(tableData, []string{"Allowlist Revision", browser.Network.AllowlistState.Revision}) + if browser.Network.AllowlistState.MutationID != "" { + tableData = append(tableData, []string{"Allowlist Mutation ID", browser.Network.AllowlistState.MutationID}) + } + } tableData = append(tableData, []string{"Proxy Routes", formatProxyRoutes(browser.Network)}) if vaults := formatVaultReferences(browser.Vaults); vaults != "" { tableData = append(tableData, []string{"Vaults", vaults}) @@ -1258,6 +1286,13 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return err } hasAllowedHostsChange := len(allowedHosts) > 0 || in.ClearAllowedHosts + conditionalAllowlistUpdate := in.ExpectedAllowlistRevision != "" || in.MutationID != "" + if (in.ExpectedAllowlistRevision == "") != (in.MutationID == "") { + return fmt.Errorf("--expected-allowlist-revision and --mutation-id must be passed together") + } + if conditionalAllowlistUpdate && !hasAllowedHostsChange { + return fmt.Errorf("--expected-allowlist-revision and --mutation-id require --allowed-host or --clear-allowed-hosts") + } // Validate --save-changes is only used with a profile if in.ProfileSaveChanges.Set && !hasProfileChange { @@ -1316,6 +1351,10 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } else if len(allowedHosts) > 0 { params.Network.AllowedHosts = allowedHosts } + if conditionalAllowlistUpdate { + params.Network.ExpectedAllowlistRevision = kernel.Opt(in.ExpectedAllowlistRevision) + params.Network.MutationID = kernel.Opt(in.MutationID) + } // Handle proxy changes if hasProxyChange { @@ -3128,6 +3167,12 @@ Notes: without one. Omit to leave the list unchanged; an empty list is invalid. - --clear-allowed-hosts removes the allowlist and returns to unfiltered egress. Once removed, an allowlist cannot be added back to that browser. + - To make an allowlist update conditional, copy Allowlist Revision from 'browsers get' + (or network.allowlist_state.revision from JSON), generate a unique mutation ID, and pass + both --expected-allowlist-revision and --mutation-id. Reuse the same revision, + mutation ID, and allowlist payload when retrying an uncertain update. A stale precondition + returns 409 stale_network without changing the allowlist; a GET alone does not fence an + earlier unfinished update. - Allowlist changes apply without restarting the browser. New requests to destinations no longer allowed are normally refused within a few seconds, and open connections to them are closed within about 30 seconds. Propagation can take up to 10 minutes during a deployment. @@ -3193,6 +3238,8 @@ func init() { browsersUpdateCmd.Flags().Bool("clear-tags", false, "Remove all tags from the browser session") browsersUpdateCmd.Flags().StringSlice("allowed-host", nil, "Replace an existing egress allowlist (repeat or comma-separated, max 100), using the same entry rules as 'browsers create --allowed-host'. Cannot add one to a browser created without one or after removal. Omit to leave unchanged; an empty list is invalid. Applies without restarting the browser; --start-url must be allowed by the new list. Requires proxy v3; on leased pooled browsers the pool's allowlist is restored on release (mutually exclusive with --clear-allowed-hosts). See notes for propagation timing") browsersUpdateCmd.Flags().Bool("clear-allowed-hosts", false, "Remove the egress allowlist and return to unfiltered egress; an allowlist cannot be added back to this browser") + browsersUpdateCmd.Flags().String("expected-allowlist-revision", "", "Optimistic concurrency precondition from the last browser response's network.allowlist_state.revision; requires --mutation-id and an allowlist change") + browsersUpdateCmd.Flags().String("mutation-id", "", "Unique ID for this conditional allowlist update; requires --expected-allowlist-revision. Reuse it with the same revision and payload when retrying an uncertain update") browsersUpdateCmd.Flags().String("timezone", "", "Set the session timezone (IANA, e.g. America/Los_Angeles) (mutually exclusive with --clear-timezone)") browsersUpdateCmd.Flags().Bool("clear-timezone", false, "Clear the session timezone override and use the browser region's default") browsersUpdateCmd.Flags().String("locale", "", "Set the session locale used for languages and date and number formatting (BCP 47, e.g. de-DE) (mutually exclusive with --clear-locale)") @@ -3480,6 +3527,7 @@ unrestricted code execution inside the browser VM and is not sandboxed.`, browsersCreateCmd.Flags().Bool("gpu", false, "Launch browser with hardware-accelerated GPU rendering") browsersCreateCmd.Flags().String("memory", "", "Memory for a non-GPU browser session. Headless: '1GiB' (default), '2GiB' or '4GiB'. Headful: '8GiB' (default) or '16GiB'") browsersCreateCmd.Flags().String("video-memory", "", "Video memory (VRAM) for a GPU browser session (requires --gpu): '2GiB' (default, 4 vCPU/6GiB memory) or '4GiB' (8 vCPU/12GiB memory)") + browsersCreateCmd.Flags().String("fingerprint-os", "", "Closed beta browser fingerprint OS override: macos. Contact Kernel to enable; requires --stealth=true, --headless=false, --gpu=false, and default memory") browsersCreateCmd.Flags().String("invocation-id", "", "Associate the browser session with an invocation") browsersCreateCmd.Flags().Bool("kiosk", false, "Launch browser in kiosk mode") browsersCreateCmd.Flags().IntP("timeout", "t", 60, "Timeout in seconds for the browser session") @@ -3623,6 +3671,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { gpuVal, _ := cmd.Flags().GetBool("gpu") memory, _ := cmd.Flags().GetString("memory") videoMemory, _ := cmd.Flags().GetString("video-memory") + fingerprintOS, _ := cmd.Flags().GetString("fingerprint-os") invocationID, _ := cmd.Flags().GetString("invocation-id") kioskVal, _ := cmd.Flags().GetBool("kiosk") timeout, _ := cmd.Flags().GetInt("timeout") @@ -3783,6 +3832,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, Memory: memory, VideoMemory: videoMemory, + FingerprintOS: fingerprintOS, InvocationID: invocationID, Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, ProfileID: profileID, @@ -3899,40 +3949,44 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { clearLocale, _ := cmd.Flags().GetBool("clear-locale") allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") clearAllowedHosts, _ := cmd.Flags().GetBool("clear-allowed-hosts") + expectedAllowlistRevision, _ := cmd.Flags().GetString("expected-allowlist-revision") + mutationID, _ := cmd.Flags().GetString("mutation-id") 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, - Timezone: timezone, - SetTimezone: cmd.Flags().Changed("timezone"), - ClearTimezone: clearTimezone, - Locale: locale, - SetLocale: cmd.Flags().Changed("locale"), - ClearLocale: clearLocale, - AllowedHosts: allowedHosts, - AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), - ClearAllowedHosts: clearAllowedHosts, - 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, + Timezone: timezone, + SetTimezone: cmd.Flags().Changed("timezone"), + ClearTimezone: clearTimezone, + Locale: locale, + SetLocale: cmd.Flags().Changed("locale"), + ClearLocale: clearLocale, + AllowedHosts: allowedHosts, + AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), + ClearAllowedHosts: clearAllowedHosts, + ExpectedAllowlistRevision: expectedAllowlistRevision, + MutationID: mutationID, + Output: out, }) } diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index ed3abefa..b3f0b953 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -893,7 +893,7 @@ func TestBrowsersGet_ProxyRoutes(t *testing.T) { fake := &FakeBrowsersService{ GetFunc: func(_ context.Context, _ string, _ kernel.BrowserGetParams, _ ...option.RequestOption) (*kernel.BrowserGetResponse, error) { var resp kernel.BrowserGetResponse - err := json.Unmarshal([]byte(`{"session_id":"sess-routes","network":{"private_hosts":["internal.example"],"proxy_routes":[{"hosts":["api.ipify.org"],"proxy":{"id":"resolved-proxy"}}]}}`), &resp) + err := json.Unmarshal([]byte(`{"session_id":"sess-routes","network":{"private_hosts":["internal.example"],"allowlist_state":{"revision":"rev-123","base_revision":"rev-122","mutation_id":"mutation-1"},"proxy_routes":[{"hosts":["api.ipify.org"],"proxy":{"id":"resolved-proxy"}}]}}`), &resp) require.NoError(t, err) return &resp, nil }, @@ -903,6 +903,10 @@ func TestBrowsersGet_ProxyRoutes(t *testing.T) { assert.Contains(t, outBuf.String(), "Private Hosts") assert.Contains(t, outBuf.String(), "Proxy Routes") assert.Contains(t, outBuf.String(), "api.ipify.org = resolved-proxy") + assert.Contains(t, outBuf.String(), "Allowlist Revision") + assert.Contains(t, outBuf.String(), "rev-123") + assert.Contains(t, outBuf.String(), "Allowlist Mutation ID") + assert.Contains(t, outBuf.String(), "mutation-1") jsonOutput := captureStdout(t, func() { require.NoError(t, b.Get(context.Background(), BrowsersGetInput{Identifier: "sess-routes", Output: "json"})) }) @@ -1057,6 +1061,32 @@ func TestBrowsersCreate_WithVideoMemory(t *testing.T) { assert.Error(t, b.Create(context.Background(), BrowsersCreateInput{VideoMemory: "8GiB"})) } +func TestBrowsersCreate_WithFingerprintOS(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + calls := 0 + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + calls++ + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-fingerprint"}, nil + }, + } + b := BrowsersCmd{browsers: fake} + + err := b.Create(context.Background(), BrowsersCreateInput{FingerprintOS: "macos"}) + require.NoError(t, err) + assert.Equal(t, kernel.BrowserFingerprintOsMacos, captured.Fingerprint.Os) + raw, err := captured.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), `"fingerprint":{"os":"macos"}`) + + err = b.Create(context.Background(), BrowsersCreateInput{FingerprintOS: "windows"}) + require.ErrorContains(t, err, "--fingerprint-os") + assert.Equal(t, 1, calls) +} + func TestBrowsersCreate_WithChromePolicy(t *testing.T) { setupStdoutCapture(t) @@ -3482,6 +3512,28 @@ func TestBrowsersUpdate_WithAllowedHosts_ForwardsParam(t *testing.T) { assert.Contains(t, string(raw), `"network":{"allowed_hosts":["example.com","*.example.com"]}`) } +func TestBrowsersUpdate_ConditionalAllowedHosts_ForwardsPrecondition(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"}, + AllowedHostsProvided: true, + ExpectedAllowlistRevision: "rev-123", + MutationID: "mutation-123", + }) + + require.NoError(t, err) + assert.Equal(t, "rev-123", captured.Network.ExpectedAllowlistRevision.Value) + assert.Equal(t, "mutation-123", captured.Network.MutationID.Value) + raw, marshalErr := json.Marshal(*captured) + require.NoError(t, marshalErr) + assert.Contains(t, string(raw), `"expected_allowlist_revision":"rev-123"`) + assert.Contains(t, string(raw), `"mutation_id":"mutation-123"`) +} + func TestBrowsersUpdate_ClearAllowedHosts_SendsNull(t *testing.T) { setupStdoutCapture(t) fake, captured := captureUpdateParams(t) @@ -3546,6 +3598,16 @@ func TestBrowsersUpdate_AllowedHostsValidation(t *testing.T) { in: BrowsersUpdateInput{Identifier: "s", AllowedHosts: tooMany, AllowedHostsProvided: true}, wantErr: "too many --allowed-host entries", }, + { + name: "revision without mutation id", + in: BrowsersUpdateInput{Identifier: "s", AllowedHosts: []string{"example.com"}, ExpectedAllowlistRevision: "rev-1"}, + wantErr: "--expected-allowlist-revision and --mutation-id must be passed together", + }, + { + name: "conditional fields without allowlist change", + in: BrowsersUpdateInput{Identifier: "s", Name: "new-name", SetName: true, ExpectedAllowlistRevision: "rev-1", MutationID: "mutation-1"}, + wantErr: "require --allowed-host or --clear-allowed-hosts", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/vaults.go b/cmd/vaults.go index a0b4507a..5c3bfc84 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "regexp" + "slices" "strings" "time" @@ -343,6 +344,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if !available { if actions.RecoveryRequired { message := "recovery_required: " + kernelSupportGuidance + if item.Spec.Provider == "shop_pay" { + message = "recovery_required: " + shopPayRecoveryHelp + } if actions.advertises("restart_verification") { message = "recovery_required: " + vaultRecoveryHelp } @@ -353,14 +357,22 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } return fmt.Errorf("operation %q is not advertised in available_operations; inspect the item", operation) } - if operation == "confirm_transaction" && item.Spec.Provider == "kernel" { + if operation == "confirm_transaction" && (item.Spec.Provider == "kernel" || item.Spec.Provider == "shop_pay") { if params.ConfirmTransaction.Currency != strings.ToUpper(item.Spec.Currency) || params.ConfirmTransaction.Amount > item.Spec.Amount { return fmt.Errorf("confirm_transaction currency must match the approved purchase currency and amount must not exceed the approved purchase amount; operation was not invoked") } + if item.Spec.Provider == "shop_pay" && !slices.Contains([]kernel.ConfirmTransactionVaultItemOperationRequestTransactionType{ + kernel.ConfirmTransactionVaultItemOperationRequestTransactionTypePurchase, + kernel.ConfirmTransactionVaultItemOperationRequestTransactionTypeAuthorization, + kernel.ConfirmTransactionVaultItemOperationRequestTransactionTypeCapture, + }, params.ConfirmTransaction.TransactionType) { + return fmt.Errorf("Shop Pay confirm_transaction transaction_type must be PURCHASE, AUTHORIZATION, or CAPTURE; operation was not invoked") + } } linkPurchase := (item.Type == "card" || item.Type == "payment_request") && item.Spec.Provider == "link" - if params != nil && params.Authorize != nil && !linkPurchase { - return fmt.Errorf("--browser-id and --page-url only apply to Link cards or Link payment_request items; authorize was not invoked") + checkoutAuthorizedPurchase := linkPurchase || (item.Type == "card" && item.Spec.Provider == "shop_pay") + if params != nil && params.Authorize != nil && !checkoutAuthorizedPurchase { + return fmt.Errorf("--browser-id and --page-url only apply to Link cards or payment_request items and Shop Pay cards; authorize was not invoked") } if operation == "fill" { if !linkPurchase && len(params.Fill.Fields) == 0 { @@ -377,12 +389,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par request := kernel.VaultItemPerformOperationParams{IDOrName: vault} opts := vaultWriteRequestOptions() if operation == "confirm_transaction" { - // Replace with a typed SDK parameter once the operation is generated. - body, err := json.Marshal(params.ConfirmTransaction) - if err != nil { - return err - } - opts = append(opts, option.WithRequestBody("application/json", body)) + request.OfConfirmTransaction = params.ConfirmTransaction } else if params != nil && params.Passkey != nil { request = *params.Passkey request.IDOrName = vault @@ -419,6 +426,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "authorize" && linkPurchase { return linkAuthorizeError(err, params != nil && params.Authorize != nil) } + if operation == "authorize" && item.Type == "card" && item.Spec.Provider == "shop_pay" { + return shopPayAuthorizeError(err, params != nil && params.Authorize != nil) + } return util.CleanedUpSdkError{Err: err} } if isPasskeyOperation(operation) { diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 42306604..7faab832 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -87,35 +87,36 @@ otherwise, the api resolves the project from your credentials and its defaults. vault names, item keys, and project ownership are immutable. 1. create/select a vault, then create a provider wallet and follow its returned action. - for kernel, share the hosted card_enrollment url; card details stay on that page. + for kernel, share the hosted card_enrollment url; for Shop Pay, share shop_pay_oauth. + card details and OAuth approval stay on those provider pages. 2. inspect wallets payment-methods for eligibility and reasons before creating a purchase. for Link, select a payment method id explicitly. 3. attach the vault with browsers create --vault ; attachment is required for - authorizing a Link card at a checkout and for fill; the same applies to a Link - payment_request. for Link, reach the final checkout - page before creating the purchase. -4. create a purchase with cards create, or where enabled use payment-requests create - for a Link or KERNEL purchase. pass --provider and --spec json. + authorizing a Link or Shop Pay card at a checkout and for fill; the same applies to a + Link payment_request. reach the final checkout page before checkout-bound authorization. +4. create a purchase with cards create (Link, AgentCard, KERNEL, or Shop Pay), or where + enabled use payment-requests create for a Link or KERNEL purchase. pass --provider and --spec json. 5. inspect items get, then use items invoke only when advertised. follow the operation description and any returned provider action. -6. ready Link and kernel cards use only advertised fill with --params for browser checkout; - equivalent payment_request items do too. neither exposes aliases or supports egress +6. ready Link, KERNEL, and Shop Pay cards use only advertised fill with --params for browser checkout; + equivalent Link and KERNEL payment_request items do too. none exposes aliases or supports egress substitution. kernel fill is locked to merchant_url's origin; submit the merchant checkout before the purchase expires. KERNEL card and payment_request purchases have no stored billing fields; enter billing details on the merchant page. AgentCard-only checkout aliases support egress substitution with checkout hold, approval, and replay; they are not a fallback after fill. -7. after the user submits KERNEL card or payment_request checkout, report the observed - outcome once with confirm_transaction when advertised. ` + kernelTransactionHelp + `. +7. after the user submits KERNEL card/payment_request or Shop Pay card checkout, report the + observed outcome once with confirm_transaction when advertised. ` + kernelTransactionHelp + `. in recovery_required, ` + vaultRecoveryHelp + ` ` + linkCardFlowHelp + ` -permitted checkout domains are displayed when returned. Link card and payment_request fill -is limited to the origin where the purchase was authorized (or merchant_url's origin for a -virtual card authorized without a checkout); kernel card and payment_request fill uses the -exact origin of merchant_url; +` + shopPayFlowHelp + ` + +permitted checkout domains are displayed when returned. Link card/payment_request and Shop Pay +card fill is limited to the origin where the purchase was authorized (or merchant_url's origin +when authorized without a checkout); kernel card and payment_request fill uses the exact origin of merchant_url; AgentCard domains are provider-assigned. there is no domain-setting api. never supply card data, OAuth codes, ciphertext, or secrets in shell arguments. use vault-provider-configs for client credentials and wallets create --tokens-file @@ -205,13 +206,13 @@ each vault has its own key; it is created on first request and may be cached.`, Long: `retrieve the item and invoke only an operation listed in available_operations. collect returns a time-scoped url for the full credential form without clearing values. authorize sends {"type":"authorize"} for payment authorization. for a Link card or -payment_request at the final checkout page, add --browser-id and --page-url (both or -neither) to send browser_id and page_url; see Link checkout below. +payment_request, or a Shop Pay card, at the final checkout page, add --browser-id and +--page-url (both or neither) to send browser_id and page_url; see provider flows below. read the operation description and follow any approval requirements before invoking. fill requires --params json or --spec-file with browser_id (session id, not name) and the inputs the item's advertised fill description asks for: 1-32 ordered fields -(field, selector) for credentials, kernel card/payment_request items, and Link virtual -cards/payment requests; no fields for a Link Pay Token. do not include type, values, or frame ids. +(field, selector) for credentials, KERNEL card/payment_request items, Shop Pay cards, +and Link virtual cards/payment requests; no fields for a Link Pay Token. do not include type, values, or frame ids. the vault must already be attached to the browser. page_url selects an existing page; fill never navigates. credentials use declared field names, must omit format, and may omit page_url only when the api can resolve a unique page. totp codes stay server-generated. @@ -219,13 +220,14 @@ payment card and payment_request items require an exact https page_url. Link vir exp_year (YYYY), billing_name, billing_line1, billing_line2, billing_city, billing_state, billing_postal_code, billing_country. expiration requires format MM/YY or MM/YYYY. deprecated timeout_ms is accepted for compatibility but ignored; fill sets no operation deadline and adapts typing pace to value length. -kernel card and payment_request items support number, expiration (format MM/YY or MM/YYYY), cvc, exp_month, and exp_year. -kernel cards have no stored billing fields; kernel payment_request items have the same boundary: billing_* returns field_unavailable (400) before +KERNEL card and payment_request items support number, expiration (format MM/YY or MM/YYYY), cvc, exp_month, and exp_year. +Shop Pay cards support those fields plus available billing fields; billing_first_name and billing_last_name are available only when Shop returned them. +KERNEL cards have no stored billing fields; KERNEL payment_request items have the same boundary: billing_* returns field_unavailable (400) before any write. enter billing details on the merchant page. the api searches the page and descendant frames, including payment iframes. -fill is available for credential items and ready Link or kernel cards when advertised, not AgentCard. -equivalent Link and kernel payment_request items also use fill when advertised. -Link and kernel cards do not expose aliases or support egress substitution; payment_request +fill is available for credential items and ready Link, KERNEL, or Shop Pay cards when advertised, not AgentCard. +equivalent Link and KERNEL payment_request items also use fill when advertised. +Link, KERNEL, and Shop Pay cards do not expose aliases or support egress substitution; payment_request items have the same boundary. kernel fill is locked to merchant_url's origin and its one-time code expires; submit checkout before expiry. fill writes real values into the browser; unrestricted browser/cdp access can read them. @@ -249,12 +251,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. -confirm_transaction requires --params or --spec-file without type, with all of: -status (APPROVED, DECLINED, PENDING, ERROR, CANCELLED), transaction_type (PURCHASE, -AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, FRAUD), amount -(integer minor units 0..1000000, no greater than the approved amount), currency (three -uppercase letters matching the approved purchase currency), and occurred_at (RFC3339). -no other fields are accepted. ` + kernelTransactionHelp + `. +confirm_transaction reports a real merchant outcome for an issued KERNEL Visa or Shop Pay +credential. it requires --params or --spec-file without type, with all of: status (APPROVED, +DECLINED, PENDING, ERROR, CANCELLED), transaction_type (PURCHASE, AUTHORIZATION, CAPTURE, +REFUND, REVERSAL, VERIFICATION, CHARGEBACK, FRAUD), amount (integer minor units 0..1000000, +no greater than the approved amount), currency (three uppercase letters matching the approved +purchase currency), and occurred_at (RFC3339). Shop Pay accepts only PURCHASE, AUTHORIZATION, +or CAPTURE. an APPROVED outcome may add order_id (1..255 characters) and order_url (https, +max 2048 characters); other outcomes may add failure_code (1..64 lowercase letters, digits, +or underscores). payment_declined is only for DECLINED, which accepts no other failure code. +unknown fields are rejected. never infer success; each purchase accepts one report. ` + kernelTransactionHelp + `. add_payment_method takes no parameters and is valid only when advertised by a connected KERNEL wallet. each call creates a separate pending enrollment with a single-use url that expires after 30 minutes; share it only with the cardholder and never retry automatically. @@ -288,6 +294,8 @@ and never retry automatically. 400/403/404/409 rejections mean the tool was not ` + linkCardFlowHelp + ` +` + shopPayFlowHelp + ` + 1Password credentials (see credentials --help) use --params or --spec-file without type: 1pw_create_access_request: no browser needed; optional goal (<=140), reason (<=100), keywords (1-5 strings); reason and keywords only for single-entry credentials. @@ -371,15 +379,15 @@ JSON 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; cardholder links are human-only, agents must share privately") - invoke.Flags().String("browser-id", "", "browser session id, not a reusable name; used by Link authorize (with --page-url) and passkey operations") - invoke.Flags().String("page-url", "", "Link authorize only: exact current top-level checkout url open in --browser-id") + invoke.Flags().String("browser-id", "", "browser session id, not a reusable name; used by Link or Shop Pay authorize (with --page-url) and passkey operations") + invoke.Flags().String("page-url", "", "Link or Shop Pay authorize only: exact current top-level checkout url open in --browser-id") invoke.Flags().String("field", "", "passkey field to arm, receive a registration, or disarm; arm/register require it when ambiguous, while disarm omits it to affect every field") addVaultJSONOutputFlag(invoke) items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultWebMCPCommand(), newVaultDeleteCommand(true)) wallets := &cobra.Command{Use: "wallets", Short: "connect provider wallets and inspect funding methods"} - walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: "create a wallet at an immutable key and follow the returned provider action.\na connected Link wallet pays at checkout through card or payment_request items; its returned description and the flow below describe how.\n" + vaultSpecHelp + vaultWalletSpecHelp + "\n" + linkCardFlowHelp, + 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.\na connected Link wallet pays through card or payment_request items; a connected Shop Pay wallet pays through card items. returned descriptions and the flows below are authoritative.\n" + vaultSpecHelp + vaultWalletSpecHelp + "\n" + linkCardFlowHelp, Example: ` kernel vaults wallets create checkout wallet-1 \ --provider link --spec '{ "authorization": { @@ -391,7 +399,11 @@ JSON kernel vaults wallets create checkout wallet-1 \ --provider agentcard --spec '{}' - kernel vaults wallets create checkout cardholder --provider kernel --spec '{}' --open`, + kernel vaults wallets create checkout cardholder --provider kernel --spec '{}' --open + + kernel vaults wallets create checkout shopper --provider shop_pay --spec '{ + "authorization":{"method":"oauth","client":{"type":"kernel_managed"}} + }' --open`, RunE: func(cmd *cobra.Command, args []string) error { spec, err := vaultWalletSpecFromFlags(cmd) if err != nil { @@ -448,7 +460,7 @@ func newVaultDeleteCommand(item bool) *cobra.Command { yes, _ := cmd.Flags().GetBool("yes") return getVaultsHandler(cmd).Delete(cmd.Context(), args[0], key, yes) }} - cmd.Flags().BoolP("yes", "y", false, "skip confirmation prompt; abandon recovery only after explicit user confirmation") + cmd.Flags().BoolP("yes", "y", false, "skip confirmation prompt; Link, KERNEL, or Shop Pay issuance recovery may be abandoned only by deleting the card directly after explicit user confirmation") return cmd } @@ -457,9 +469,9 @@ func newVaultCardCommand(update bool) *cobra.Command { if update { use, short = "update", "update a card spec before it is authorized at a checkout" } - providers := "link|agentcard|kernel" + providers := "link|agentcard|kernel|shop_pay" if update { - providers = "link|agentcard" + providers = "link|agentcard|shop_pay" } cmd := &cobra.Command{Use: use + " --provider <" + providers + "> --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: short + `. neither create nor update authorizes a card. @@ -527,8 +539,8 @@ never create another request to retry an uncertain payment. if err != nil { return err } - if string(spec["provider"]) == `"agentcard"` { - return fmt.Errorf("payment_request items support only link or kernel; AgentCard reusable cards use vaults cards") + if string(spec["provider"]) != `"link"` && string(spec["provider"]) != `"kernel"` { + return fmt.Errorf("payment_request items support only link or kernel; AgentCard and Shop Pay purchases use vaults cards") } return getVaultsHandler(cmd).SavePaymentRequest(cmd.Context(), args[0], args[1], param.Override[kernel.PaymentRequestVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) }, @@ -540,7 +552,7 @@ never create another request to retry an uncertain payment. } func addVaultSpecFlags(cmd *cobra.Command) { - cmd.Flags().String("provider", "", "provider: link, agentcard, or kernel (required)") + cmd.Flags().String("provider", "", "provider: link, agentcard, kernel, or shop_pay (required)") cmd.Flags().String("spec", "", "raw json specification object (required); see types and examples above") _ = cmd.MarkFlagRequired("provider") _ = cmd.MarkFlagRequired("spec") @@ -548,8 +560,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" && provider != "kernel" { - return nil, fmt.Errorf("--provider must be link, agentcard, or kernel") + if provider != "link" && provider != "agentcard" && provider != "kernel" && provider != "shop_pay" { + return nil, fmt.Errorf("--provider must be link, agentcard, kernel, or shop_pay") } raw, _ := cmd.Flags().GetString("spec") var spec map[string]json.RawMessage diff --git a/cmd/vaults_confirm_transaction.go b/cmd/vaults_confirm_transaction.go index c1e1c80b..4e07a702 100644 --- a/cmd/vaults_confirm_transaction.go +++ b/cmd/vaults_confirm_transaction.go @@ -3,47 +3,102 @@ package cmd import ( "encoding/json" "fmt" + "net/url" "regexp" "slices" + "strings" "time" + "unicode/utf8" + + kernel "github.com/kernel/kernel-go-sdk" ) var vaultCurrencyPattern = regexp.MustCompile(`^[A-Z]{3}$`) +var vaultFailureCodePattern = regexp.MustCompile(`^[a-z0-9_]{1,64}$`) -type vaultConfirmTransactionParams struct { - Type string `json:"type"` - Status string `json:"status"` - TransactionType string `json:"transaction_type"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - OccurredAt string `json:"occurred_at"` -} - -func parseVaultConfirmTransactionParams(raw string) (*vaultConfirmTransactionParams, error) { - object, err := vaultParamsObject(raw, "status transaction_type amount currency occurred_at") +func parseVaultConfirmTransactionParams(raw string) (*kernel.ConfirmTransactionVaultItemOperationRequestParam, error) { + object, err := vaultParamsObject(raw, "status transaction_type amount currency occurred_at failure_code order_id order_url") if err != nil { return nil, err } - params := vaultConfirmTransactionParams{Type: "confirm_transaction"} - if json.Unmarshal(object["status"], ¶ms.Status) != nil || !slices.Contains([]string{"APPROVED", "DECLINED", "PENDING", "ERROR", "CANCELLED"}, params.Status) { + + var status string + if json.Unmarshal(object["status"], &status) != nil || !slices.Contains([]string{"APPROVED", "DECLINED", "PENDING", "ERROR", "CANCELLED"}, status) { return nil, fmt.Errorf("status must be APPROVED, DECLINED, PENDING, ERROR, or CANCELLED") } - if json.Unmarshal(object["transaction_type"], ¶ms.TransactionType) != nil || !slices.Contains([]string{"PURCHASE", "AUTHORIZATION", "CAPTURE", "REFUND", "REVERSAL", "VERIFICATION", "CHARGEBACK", "FRAUD"}, params.TransactionType) { + var transactionType string + if json.Unmarshal(object["transaction_type"], &transactionType) != nil || !slices.Contains([]string{"PURCHASE", "AUTHORIZATION", "CAPTURE", "REFUND", "REVERSAL", "VERIFICATION", "CHARGEBACK", "FRAUD"}, transactionType) { return nil, fmt.Errorf("transaction_type must be PURCHASE, AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, or FRAUD") } var amount *int64 if json.Unmarshal(object["amount"], &amount) != nil || amount == nil || *amount < 0 || *amount > 1_000_000 { return nil, fmt.Errorf("amount must be an integer between 0 and 1000000 minor units, no greater than the approved amount") } - params.Amount = *amount - if json.Unmarshal(object["currency"], ¶ms.Currency) != nil || !vaultCurrencyPattern.MatchString(params.Currency) { + var currency string + if json.Unmarshal(object["currency"], ¤cy) != nil || !vaultCurrencyPattern.MatchString(currency) { return nil, fmt.Errorf("currency must be a three-letter uppercase code") } - if json.Unmarshal(object["occurred_at"], ¶ms.OccurredAt) != nil { + var occurredAtText string + if json.Unmarshal(object["occurred_at"], &occurredAtText) != nil { return nil, fmt.Errorf("occurred_at must be an RFC3339 timestamp") } - if _, err := time.Parse(time.RFC3339, params.OccurredAt); err != nil { + occurredAt, err := time.Parse(time.RFC3339, occurredAtText) + if err != nil { return nil, fmt.Errorf("occurred_at must be an RFC3339 timestamp") } + + params := kernel.ConfirmTransactionVaultItemOperationRequestParam{ + Amount: *amount, + Currency: currency, + OccurredAt: occurredAt, + Status: kernel.ConfirmTransactionVaultItemOperationRequestStatus(status), + TransactionType: kernel.ConfirmTransactionVaultItemOperationRequestTransactionType(transactionType), + Type: kernel.ConfirmTransactionVaultItemOperationRequestTypeConfirmTransaction, + } + // The SDK's date-time encoder uses whole-second RFC3339. Preserve the user's + // valid fractional seconds exactly while still using the generated request type. + params.SetExtraFields(map[string]any{"occurred_at": occurredAtText}) + + if rawOrderID, ok := object["order_id"]; ok { + var orderID string + if json.Unmarshal(rawOrderID, &orderID) != nil || strings.TrimSpace(orderID) == "" || utf8.RuneCountInString(orderID) > 255 { + return nil, fmt.Errorf("order_id must be a non-empty string of at most 255 characters") + } + params.OrderID = kernel.Opt(orderID) + } + if rawOrderURL, ok := object["order_url"]; ok { + var orderURL string + if json.Unmarshal(rawOrderURL, &orderURL) != nil || len(orderURL) > 2048 { + return nil, fmt.Errorf("order_url must be an https URL of at most 2048 characters") + } + u, err := url.ParseRequestURI(orderURL) + if err != nil || u.Scheme != "https" || u.Host == "" { + return nil, fmt.Errorf("order_url must be an https URL of at most 2048 characters") + } + params.OrderURL = kernel.Opt(orderURL) + } + if rawFailureCode, ok := object["failure_code"]; ok { + var failureCode string + if json.Unmarshal(rawFailureCode, &failureCode) != nil || !vaultFailureCodePattern.MatchString(failureCode) { + return nil, fmt.Errorf("failure_code must contain 1-64 lowercase letters, digits, or underscores") + } + params.FailureCode = kernel.Opt(failureCode) + } + + approved := params.Status == kernel.ConfirmTransactionVaultItemOperationRequestStatusApproved + if approved && params.FailureCode.Valid() { + return nil, fmt.Errorf("failure_code is only for outcomes other than APPROVED") + } + if !approved && (params.OrderID.Valid() || params.OrderURL.Valid()) { + return nil, fmt.Errorf("order_id and order_url are only for an APPROVED outcome") + } + if params.FailureCode.Valid() { + if params.FailureCode.Value == "payment_declined" && params.Status != kernel.ConfirmTransactionVaultItemOperationRequestStatusDeclined { + return nil, fmt.Errorf("failure_code payment_declined requires status DECLINED") + } + if params.Status == kernel.ConfirmTransactionVaultItemOperationRequestStatusDeclined && params.FailureCode.Value != "payment_declined" { + return nil, fmt.Errorf("status DECLINED accepts only failure_code payment_declined") + } + } return ¶ms, nil } diff --git a/cmd/vaults_confirm_transaction_test.go b/cmd/vaults_confirm_transaction_test.go index 7feed3c0..c22c10dc 100644 --- a/cmd/vaults_confirm_transaction_test.go +++ b/cmd/vaults_confirm_transaction_test.go @@ -120,10 +120,73 @@ func TestVaultConfirmTransactionValidValues(t *testing.T) { } } +func TestVaultConfirmTransactionShopPayOutcomeDetails(t *testing.T) { + approved := `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1234,"currency":"USD","occurred_at":"2026-09-01T12:00:00.123Z","order_id":"order-123","order_url":"https://shop.example/orders/123"}` + params, err := parseVaultOperationParams("confirm_transaction", approved, true, false) + require.NoError(t, err) + assert.Equal(t, "order-123", params.ConfirmTransaction.OrderID.Value) + assert.Equal(t, "https://shop.example/orders/123", params.ConfirmTransaction.OrderURL.Value) + assert.False(t, params.ConfirmTransaction.FailureCode.Valid()) + + failed := `{"status":"ERROR","transaction_type":"CAPTURE","amount":1234,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","failure_code":"site_error"}` + params, err = parseVaultOperationParams("confirm_transaction", failed, true, false) + require.NoError(t, err) + assert.Equal(t, "site_error", params.ConfirmTransaction.FailureCode.Value) + + for raw, message := range map[string]string{ + `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","failure_code":"site_error"}`: "only for outcomes other than APPROVED", + `{"status":"ERROR","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","order_id":"order-1"}`: "only for an APPROVED outcome", + `{"status":"ERROR","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","failure_code":"UPPERCASE"}`: "lowercase letters", + `{"status":"DECLINED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","failure_code":"site_error"}`: "accepts only failure_code payment_declined", + `{"status":"ERROR","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","failure_code":"payment_declined"}`: "requires status DECLINED", + `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","order_url":"http://shop.example/orders/1"}`: "https URL", + `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z","order_id":""}`: "non-empty string", + } { + _, err := parseVaultOperationParams("confirm_transaction", raw, true, false) + require.ErrorContains(t, err, message) + } +} + +func TestVaultShopPayConfirmTransactionUsesTypedFields(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + fixture := strings.Replace(shopPayCardFixture, `{"type":"authorize","description":"Authorize this Shop Pay purchase."}`, `{"type":"confirm_transaction","description":"Report the Shop Pay merchant outcome."}`, 1) + calls, posts := 0, 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"confirm_transaction","status":"APPROVED","transaction_type":"PURCHASE","amount":1234,"currency":"USD","occurred_at":"2026-09-01T12:00:00.123Z","order_id":"order-123","order_url":"https://shop.example/orders/123"}`, string(body)) + _, _ = io.WriteString(w, strings.Replace(strings.Replace(fixture, `"status":"requested"`, `"status":"consumed"`, 1), `[{"type":"confirm_transaction","description":"Report the Shop Pay merchant outcome."}]`, `[]`, 1)) + return + } + _, _ = io.WriteString(w, fixture) + }) + + raw := `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1234,"currency":"USD","occurred_at":"2026-09-01T12:00:00.123Z","order_id":"order-123","order_url":"https://shop.example/orders/123"}` + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "confirm_transaction", "--params", raw, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.Equal(t, 1, posts) + + calls, posts = 0, 0 + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "confirm_transaction", "--params", `{"status":"APPROVED","transaction_type":"REFUND","amount":1234,"currency":"USD","occurred_at":"2026-09-01T12:00:00Z"}`) + require.ErrorContains(t, err, "Shop Pay confirm_transaction transaction_type") + assert.Equal(t, 1, calls) + assert.Equal(t, 0, posts) +} + func TestVaultConfirmTransactionHelpUsesCurrentAmountMaximum(t *testing.T) { cmd, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) require.NoError(t, err) assert.Contains(t, cmd.Long, "0..1000000") + assert.Contains(t, cmd.Long, "order_id") + assert.Contains(t, cmd.Long, "order_url") + assert.Contains(t, cmd.Long, "failure_code") + assert.Contains(t, cmd.Long, "Shop Pay accepts only PURCHASE, AUTHORIZATION,") + assert.Contains(t, cmd.Long, "or CAPTURE") assert.NotContains(t, cmd.Long, "0..50000") } diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 72951b70..467e79c1 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -14,6 +14,25 @@ or normalization. never include card data, OAuth tokens, or provider secrets in const vaultRecoveryHelp = "only if restart_verification is advertised in available_operations, invoke it with explicit user approval each time to get a fresh spend_approval link. restart grants no spend consent: the cardholder must verify and explicitly approve spending again on the new link. if the result is unclear, re-get and share the new action if present; invoke once more only if restart_verification is still advertised and the user approves again. otherwise " + kernelSupportGuidance +const shopPayRecoveryHelp = "follow status_reason. Shop Pay issuance recovery may be explicitly abandoned by deleting the card directly after user confirmation, then creating a new purchase for separate approval. recovery while reporting a merchant transaction cannot be deleted; reconcile it with support. deletion or recreation is never proof that no payment occurred" + +const shopPayFlowHelp = `Shop Pay checkout (the item's available_operations descriptions are authoritative): +1. create a Shop Pay wallet with --provider shop_pay and Kernel-managed OAuth authorization, + share the returned shop_pay_oauth URL unchanged with the shopper, and wait for connected. +2. inspect wallets payment-methods. a card spec may select payment_method_id or omit it to + use the shopper's preferred card. +3. create a card with amount, USD currency, merchant details, shopper-facing context, and + totals containing exactly one positive total no greater than amount. +4. after explicit user approval, invoke authorize. at final checkout pass --browser-id and + --page-url together so Shop receives that page as the merchant and fill is locked to its + origin; omit both to use merchant_url. share any approval URL unchanged and observe the item. +5. when fill is advertised, fill only the fields requested by the checkout and submit it. + ready means a credential was issued, not that the merchant accepted payment. +6. report the observed merchant outcome once with confirm_transaction. Shop Pay accepts + PURCHASE, AUTHORIZATION, or CAPTURE; APPROVED may include order_id/order_url, while other + outcomes may include failure_code. reporting consumes the card whatever the outcome. +never infer success or retry an unknown payment; reconcile the merchant order and original item.` + const vaultWalletSpecHelp = ` for --provider kernel, use --spec '{}'. if creation returns card_enrollment, share it with the cardholder; never enter card details in the cli. where the wallet @@ -24,6 +43,8 @@ a connected wallet may have zero cards or cards that are still ineligible while token enrollment runs. ` + kernelWalletHelp + `. ` + kernelEligibilityHelp + `. kernel wallets accept neither provider configuration flags nor --tokens-file. +Shop Pay wallets also accept neither: they always use Kernel-managed OAuth. creation returns +shop_pay_oauth; share its URL unchanged with the shopper and observe with wallets get --wait 60. for Link and AgentCard, omit config selection to preserve managed defaults. --provider-config-id and --provider-config-name are mutually exclusive. for Link with selection flags, use --spec '{}' and --tokens-file . @@ -67,6 +88,16 @@ type AgentCardWalletSpec = { type KernelWalletSpec = { provider: "kernel"; }; + +type ShopPayWalletSpec = { + provider: "shop_pay"; + authorization: { + method: "oauth"; + client: { type: "kernel_managed" }; + }; +}; + +` + shopPayFlowHelp + ` ` const vaultCardSpecHelp = ` @@ -121,6 +152,31 @@ type KernelCardSpec = { merchant_category_code?: string; // actual four-digit MCC, not 0000; omit rather than invent }; +type ShopPayCardSpec = { + provider: "shop_pay"; + wallet: string; // connected Shop Pay wallet item key + payment_method_id?: string; // from wallets payment-methods; omit for the shopper's preferred card + amount: number; // approval limit in minor units; 1..1000000 + currency: string; // USD only + merchant_name: string; // 1..255 characters + merchant_url: string; // merchant https URL; used when authorize omits checkout context + merchant_country?: string; // iso 3166-1 alpha-2 + context: string; // 100..500 characters shown to the shopper + line_items?: ShopPayLineItem[]; + totals: ShopPayTotal[]; // exactly one positive total, no greater than amount +}; + +type ShopPayLineItem = { + name: string; + quantity: number; // integer >= 1 + unit_amount: number; // integer minor units >= 0 +}; + +type ShopPayTotal = { + type: string; // e.g. subtotal, fulfillment, tax, discount, total + amount: number; // integer minor units; discounts are negative +}; + type LinkLineItem = { name: string; quantity?: number; // integer >= 1 @@ -166,9 +222,12 @@ it retains the current approval flow; even when supplied, autopilot may not appl user approval may still be required. this field does not guarantee payment success. prepared checkout uses preparation merchant_origin instead. -permitted domains are not configurable in the spec. Link card fill is limited to the origin -of the checkout the card was authorized at; AgentCard domains are provider-assigned. +permitted domains are not configurable in the spec. Link and Shop Pay card fill is limited +to the checkout origin when authorized with browser_id/page_url, or merchant_url's origin +when authorized without them; AgentCard domains are provider-assigned. for KERNEL purchases, ` + kernelFundingStatusHelp + `. + +` + shopPayFlowHelp + ` ` const vaultPaymentRequestSpecHelp = ` diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index 979475a3..969dba29 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -198,7 +199,9 @@ func TestVaultInvokeConfirmTransactionSendsObservedOutcome(t *testing.T) { raw := fmt.Sprintf(`{"status":"APPROVED","transaction_type":"PURCHASE","amount":1234,"currency":"USD","occurred_at":%q}`, occurredAt) params, err := parseVaultOperationParams("confirm_transaction", raw, true, false) require.NoError(t, err) - assert.Equal(t, occurredAt, params.ConfirmTransaction.OccurredAt) + expectedTime, err := time.Parse(time.RFC3339, occurredAt) + require.NoError(t, err) + assert.Equal(t, expectedTime, params.ConfirmTransaction.OccurredAt) calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index 4ef16415..a2bd59f0 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -126,3 +126,29 @@ func linkAuthorizeError(err error, checkout bool) error { message := kernelVaultErrorURL.ReplaceAllString(body.Message, "[redacted url]") return safeKernelAPIError(apiErr, body.Code, fmt.Sprintf("authorize failed (http %d): %s; %s", apiErr.StatusCode, message, guidance)) } + +func shopPayAuthorizeError(err error, checkout bool) error { + var apiErr *kernel.Error + if !errors.As(err, &apiErr) { + cause := kernelVaultErrorURL.ReplaceAllString(err.Error(), "[redacted url]") + return kernelVaultTransportError{message: fmt.Sprintf("Shop Pay authorize result unavailable (%s); re-get the item and follow its advertised operations. once approval is requested, authorize only re-checks it", cause), cause: err} + } + var body struct { + Code string `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal([]byte(apiErr.RawJSON()), &body) + guidance := "re-get the item and follow its advertised operations" + switch { + case linkAuthorizeBrowserCodes[body.Code]: + guidance = "no Shop Pay purchase request was made. make sure the checkout is open in exactly one tab at page_url in a browser with this vault attached, then authorize again. if checkout inspection keeps failing, authorize without --browser-id and --page-url to use merchant_url" + case apiErr.StatusCode == 429: + guidance = retryAfterWait(apiErr) + ", then authorize again" + case checkout && apiErr.StatusCode >= 500: + guidance = linkAuthorizeServerErrorGuidance + case body.Code == "invalid_request": + guidance = "fix the reported input, then authorize again only if the item still advertises authorize" + } + message := kernelVaultErrorURL.ReplaceAllString(body.Message, "[redacted url]") + return safeKernelAPIError(apiErr, body.Code, fmt.Sprintf("Shop Pay authorize failed (http %d): %s; %s", apiErr.StatusCode, message, guidance)) +} diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 9e4e832d..b242b363 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -17,7 +17,7 @@ type vaultOperationParams struct { Fill *vaultFillParams WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam Checkout *kernel.VaultCheckoutContextParam - ConfirmTransaction *vaultConfirmTransactionParams + ConfirmTransaction *kernel.ConfirmTransactionVaultItemOperationRequestParam // Passkey, OnePassword, and PaymentMethod are complete operation request bodies; // Invoke supplies the vault. Passkey *kernel.VaultItemPerformOperationParams diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index c1872022..dbfdd6e5 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -442,10 +442,12 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { } if item.Type == "wallet" { configID, configName := item.Spec.ProviderConfig.ID, item.Spec.ProviderConfig.Name - if item.Spec.Provider == "link" { + if item.Spec.Provider == "link" || item.Spec.Provider == "shop_pay" { client := item.Spec.Authorization.Client rows = append(rows, []string{"OAuth client type", client.Type}) - configID, configName = client.ProviderConfig.ID, client.ProviderConfig.Name + if item.Spec.Provider == "link" { + configID, configName = client.ProviderConfig.ID, client.ProviderConfig.Name + } } if configID != "" { rows = append(rows, []string{"provider config id (immutable)", configID}) @@ -478,10 +480,10 @@ 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 != "" { + if (item.Spec.Provider == "kernel" || item.Spec.Provider == "shop_pay") && item.Spec.MerchantURL != "" { rows = append(rows, []string{"merchant url", item.Spec.MerchantURL}) } - if item.Spec.Provider == "kernel" && item.Spec.MerchantCountry != "" { + if (item.Spec.Provider == "kernel" || item.Spec.Provider == "shop_pay") && item.Spec.MerchantCountry != "" { rows = append(rows, []string{"merchant country", item.Spec.MerchantCountry}) } if item.State.JSON.FundingStatus.Valid() { @@ -543,6 +545,9 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemActions) { if actions.RecoveryRequired { guidance := kernelSupportGuidance + if item.Spec.Provider == "shop_pay" { + guidance = shopPayRecoveryHelp + } if actions.advertises("restart_verification") { guidance = vaultRecoveryHelp } @@ -636,6 +641,8 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if item.Spec.JSON.Recurring.Valid() { pterm.Info.Println("this recurring Link purchase remains ready for merchant-scheduled charges until it expires or is deleted; do not create a new card each period, or a new payment_request for the same recurring purchase. deleting the item, wallet, or vault revokes the Link credential but does not cancel the merchant subscription. if deletion returns 409 provider_revocation_pending, retry deletion.") } + case "shop_pay": + pterm.Info.Println("Shop Pay fill never submits payment. submit checkout yourself, then report the observed merchant outcome once with confirm_transaction when advertised; reporting consumes the card whatever the outcome. never infer success or repeat an unknown checkout. " + shopPayRecoveryHelp + ".") default: pterm.Info.Println("inspect items events for payment outcomes. never retry automatically; if recovery permits abandonment, delete the purchase item only after explicit user confirmation before creating a replacement.") } @@ -647,6 +654,9 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction if item.Spec.Provider == "kernel" { pterm.Info.Println(kernelWalletHelp) } + if item.Spec.Provider == "shop_pay" { + pterm.Info.Println("the connected wallet's payment_methods expansion lists the shopper's saved Shop Pay cards. create a separate card item for each purchase and follow its advertised authorize, fill, and confirm_transaction operations.") + } for _, expansion := range wallet.AvailableExpansions { pterm.Printf("available expansion: %s — %s\n", expansion.Type, expansion.Description) } diff --git a/cmd/vaults_payment_help_test.go b/cmd/vaults_payment_help_test.go index 13e35d25..2dce03a8 100644 --- a/cmd/vaults_payment_help_test.go +++ b/cmd/vaults_payment_help_test.go @@ -17,9 +17,9 @@ func TestVaultPaymentHelpProviderBoundaries(t *testing.T) { { name: "vaults", want: []string{ - "attachment is required for authorizing a Link card at a checkout and for fill", - "ready Link and kernel cards use only advertised fill", - "neither exposes aliases or supports egress substitution", + "attachment is required for authorizing a Link or Shop Pay card at a checkout and for fill", + "ready Link, KERNEL, and Shop Pay cards use only advertised fill", + "none exposes aliases or supports egress substitution", "AgentCard-only checkout aliases support egress substitution with checkout hold, approval, and replay", "not a fallback after fill", }, @@ -34,8 +34,8 @@ func TestVaultPaymentHelpProviderBoundaries(t *testing.T) { path: []string{"items", "invoke"}, want: []string{ "the vault must already be attached to the browser", - "fill is available for credential items and ready Link or kernel cards when advertised, not AgentCard", - "Link and kernel cards do not expose aliases or support egress substitution", + "fill is available for credential items and ready Link, KERNEL, or Shop Pay cards when advertised, not AgentCard", + "Link, KERNEL, and Shop Pay cards do not expose aliases or support egress substitution", "fill writes real values into the browser; unrestricted browser/cdp access can read them", "fill never explicitly submits forms or clicks buttons", "input/change events may trigger site behavior", @@ -47,7 +47,7 @@ func TestVaultPaymentHelpProviderBoundaries(t *testing.T) { "report one observed merchant outcome", "unknown means no reliable merchant outcome", "restart_verification takes no parameters", - "kernel cards have no stored billing fields", + "KERNEL cards have no stored billing fields", "billing_* returns field_unavailable (400) before any write", "enter billing details on the merchant page", }, diff --git a/cmd/vaults_shop_pay_test.go b/cmd/vaults_shop_pay_test.go new file mode 100644 index 00000000..76f36943 --- /dev/null +++ b/cmd/vaults_shop_pay_test.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const shopPayWalletFixture = `{"id":"wallet-id","key":"shopper","type":"wallet","spec":{"provider":"shop_pay","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"shop_pay","status":"pending_authorization"},"action":{"name":"shop_pay_oauth","url":"https://shop.example/connect"},"available_operations":[],"available_expansions":[{"type":"payment_methods","description":"List saved Shop Pay cards."}]}` + +const shopPayCardFixture = `{"id":"card-id","key":"order-1","type":"card","spec":{"provider":"shop_pay","wallet":"shopper","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US","context":"Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose.","totals":[{"type":"total","amount":1234}]},"state":{"provider":"shop_pay","status":"requested"},"available_operations":[{"type":"authorize","description":"Authorize this Shop Pay purchase."}],"available_expansions":[]}` + +func TestVaultShopPayWalletCreate(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/vaults/checkout/items/shopper", r.URL.Path) + var body struct { + Type string `json:"type"` + Spec json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "wallet", body.Type) + assert.JSONEq(t, `{"provider":"shop_pay","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}`, string(body.Spec)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, shopPayWalletFixture) + }) + + out, _, err := executeVaultCommand(t, client, "vaults", "wallets", "create", "checkout", "shopper", + "--provider", "shop_pay", "--spec", `{"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}`, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.Contains(t, out, `"provider": "shop_pay"`) + assert.Contains(t, out, `"name": "shop_pay_oauth"`) +} + +func TestVaultShopPayWalletValidation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("request should not be sent") }) + for _, tt := range []struct { + name string + args []string + wantErr string + }{ + {"missing authorization", []string{"--spec", `{}`}, "spec.authorization is required"}, + {"wrong client", []string{"--spec", `{"authorization":{"method":"oauth","client":{"type":"customer_managed"}}}`}, "client.type kernel_managed"}, + {"provider config", []string{"--spec", `{"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}`, "--provider-config-id", "config-1"}, "do not accept provider configurations or tokens"}, + } { + t.Run(tt.name, func(t *testing.T) { + args := []string{"vaults", "wallets", "create", "checkout", "shopper", "--provider", "shop_pay"} + args = append(args, tt.args...) + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestVaultShopPayCardCreateAndUpdate(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"wallet":"shopper","amount":1234,"currency":"USD","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US","context":"Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose.","line_items":[{"name":"Item","quantity":1,"unit_amount":1234}],"totals":[{"type":"total","amount":1234}]}` + for _, operation := range []string{"create", "update"} { + t.Run(operation, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if operation == "create" { + assert.Equal(t, http.MethodPut, r.Method) + } else { + assert.Equal(t, http.MethodPatch, r.Method) + } + 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, "card", body.Type) + assert.JSONEq(t, `"shop_pay"`, string(body.Spec["provider"])) + assert.JSONEq(t, `[{"type":"total","amount":1234}]`, string(body.Spec["totals"])) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, shopPayCardFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "cards", operation, "checkout", "order-1", "--provider", "shop_pay", "--spec", spec, "-o", "json") + require.NoError(t, err) + }) + } +} + +func TestVaultPaymentRequestRejectsShopPay(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("request should not be sent") }) + _, _, err := executeVaultCommand(t, client, "vaults", "payment-requests", "create", "checkout", "order-1", "--provider", "shop_pay", "--spec", `{}`) + require.ErrorContains(t, err, "support only link or kernel") +} + +func TestVaultShopPayAuthorizeAcceptsCheckoutContext(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + assert.Equal(t, http.MethodGet, r.Method) + _, _ = io.WriteString(w, shopPayCardFixture) + return + } + assert.Equal(t, http.MethodPost, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"authorize","browser_id":"browser-1","page_url":"https://shop.example/checkout"}`, string(body)) + _, _ = io.WriteString(w, strings.Replace(shopPayCardFixture, `"status":"requested"`, `"status":"pending_authorization"`, 1)) + }) + + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", + "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) +} diff --git a/cmd/vaults_spec_test.go b/cmd/vaults_spec_test.go index ef8a2ab1..4b2d9451 100644 --- a/cmd/vaults_spec_test.go +++ b/cmd/vaults_spec_test.go @@ -87,12 +87,13 @@ func TestVaultSpecHelpAndFlags(t *testing.T) { assert.Contains(t, cmd.Long, "TypeScript notation") assert.Contains(t, cmd.Long, "provider: \"link\"") assert.Contains(t, cmd.Long, "provider: \"agentcard\"") + assert.Contains(t, cmd.Long, "provider: \"shop_pay\"") assert.Contains(t, cmd.Example, "--spec '") assert.NotContains(t, cmd.Long, "test: boolean") assert.NotContains(t, cmd.Long, "sandbox/live") assert.NotContains(t, cmd.Long, "expires_at?:") if strings.HasPrefix(path, "cards") { - for _, field := range []string{"merchant_name:", "merchant:", "line_items?:", "metadata?:", "recurring?:", "type LinkRecurring", `interval: "day" | "week" | "month" | "year"`, "checkout_origin?:", "type LinkLineItem", "type LinkTotal"} { + for _, field := range []string{"merchant_name:", "merchant:", "line_items?:", "metadata?:", "recurring?:", "type LinkRecurring", `interval: "day" | "week" | "month" | "year"`, "checkout_origin?:", "type LinkLineItem", "type LinkTotal", "type ShopPayCardSpec", "type ShopPayLineItem", "type ShopPayTotal"} { assert.Contains(t, cmd.Long, field) } for _, guidance := range []string{"eligible", "autopilot may not apply", "user approval may", "does not guarantee payment success", "KERNEL does not validate this declaration against the page open in the browser.", "preparation merchant_origin instead", "card updates replace the whole spec", "omitting checkout_origin from an update removes", "per-period spending limit", "do not create a new card each period", "does not cancel the merchant subscription", "1..1000000"} { diff --git a/cmd/vaults_wallet_spec.go b/cmd/vaults_wallet_spec.go index 4659c7b7..850c423b 100644 --- a/cmd/vaults_wallet_spec.go +++ b/cmd/vaults_wallet_spec.go @@ -46,6 +46,11 @@ func vaultWalletSpecFromFlags(cmd *cobra.Command) (kernel.VaultItemUpsertParamsB if reference != nil || cmd.Flags().Changed("tokens-file") { return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("kernel wallets do not use provider configurations or tokens") } + case "shop_pay": + if reference != nil || cmd.Flags().Changed("tokens-file") { + return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("Shop Pay wallets use Kernel-managed OAuth and do not accept provider configurations or tokens") + } + err = configureShopPayWallet(spec) case "agentcard": if cmd.Flags().Changed("tokens-file") { return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("--tokens-file is only for imported Link wallet grants") @@ -79,6 +84,23 @@ func vaultProviderConfigFromFlags(cmd *cobra.Command) (json.RawMessage, error) { return json.Marshal(reference) } +func configureShopPayWallet(spec map[string]json.RawMessage) error { + raw, ok := spec["authorization"] + if !ok { + return fmt.Errorf("Shop Pay spec.authorization is required with method oauth and client.type kernel_managed") + } + var authorization struct { + Method string `json:"method"` + Client struct { + Type string `json:"type"` + } `json:"client"` + } + if json.Unmarshal(raw, &authorization) != nil || authorization.Method != "oauth" || authorization.Client.Type != "kernel_managed" { + return fmt.Errorf("Shop Pay spec.authorization must use method oauth and client.type kernel_managed") + } + return nil +} + func configureAgentCardWallet(spec map[string]json.RawMessage, reference json.RawMessage) error { if reference != nil { if _, exists := spec["provider_config"]; exists { diff --git a/go.mod b/go.mod index a0939614..1fc00bb4 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.135.0 + github.com/kernel/kernel-go-sdk v0.136.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 96017114..73c01e7b 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,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.135.0 h1:wOID4GW1Hb73wtkEZpEYOfYInWTCQgCZ9eRpidEytk8= -github.com/kernel/kernel-go-sdk v0.135.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= +github.com/kernel/kernel-go-sdk v0.136.0 h1:+CZDkqUurKE646Y/d7OEEaa3WbZ01WVODVq+W9CoUHk= +github.com/kernel/kernel-go-sdk v0.136.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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 adeafcf9fdfb17e1890c1e125631c2dfabf6eaa5 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:33:01 +0000 Subject: [PATCH 7/8] Update Go SDK to 26119f060f1e2c69f363e627151994d3c63093e4 and CLI coverage --- cmd/auth_connections.go | 31 +++++++++- cmd/auth_connections_test.go | 108 +++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 140 insertions(+), 5 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 1b1dc6d0..ec8a8aab 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -56,6 +56,7 @@ type AuthConnectionCreateInput struct { HealthCheckInterval int NoHealthChecks bool NoAutoReauth bool + Hitl BoolFlag RecordSession BoolFlag Telemetry string TelemetryCdpExclude string @@ -94,6 +95,7 @@ type AuthConnectionUpdateInput struct { HealthCheckIntervalSet bool HealthChecks BoolFlag AutoReauth BoolFlag + Hitl BoolFlag RecordSession BoolFlag Telemetry string TelemetryCdpExclude string @@ -251,6 +253,10 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.AutoReauth = kernel.Opt(false) } + if in.Hitl.Set { + params.ManagedAuthCreateRequest.Hitl = kernel.Opt(in.Hitl.Value) + } + if in.RecordSession.Set { params.ManagedAuthCreateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) } @@ -293,6 +299,7 @@ func printManagedAuthSummary(auth *kernel.ManagedAuth) { {"Domain", auth.Domain}, {"Profile Name", auth.ProfileName}, {"Status", string(auth.Status)}, + {"Human Intervention", fmt.Sprintf("%t", auth.Hitl)}, {"Can Reauth", fmt.Sprintf("%t", auth.CanReauth)}, } if auth.CanReauthReason != "" { @@ -361,6 +368,10 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn params.ManagedAuthUpdateRequest.AutoReauth = kernel.Opt(in.AutoReauth.Value) hasChanges = true } + if in.Hitl.Set { + params.ManagedAuthUpdateRequest.Hitl = kernel.Opt(in.Hitl.Value) + hasChanges = true + } if in.RecordSession.Set { params.ManagedAuthUpdateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) hasChanges = true @@ -571,6 +582,7 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e {"Domain", auth.Domain}, {"Profile Name", auth.ProfileName}, {"Status", string(auth.Status)}, + {"Human Intervention", fmt.Sprintf("%t", auth.Hitl)}, {"Can Reauth", fmt.Sprintf("%t", auth.CanReauth)}, } if auth.CanReauthReason != "" { @@ -1191,6 +1203,9 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn if state.InteractionID != "" { pterm.Info.Printf(" Interaction ID: %s\n", state.InteractionID) } + if state.LiveViewURL != "" { + pterm.Info.Printf(" Live View URL: %s\n", state.LiveViewURL) + } if len(state.Fields) > 0 { fields := make([]string, 0, len(state.Fields)) for _, f := range state.Fields { @@ -1271,7 +1286,7 @@ var authConnectionsCreateCmd = &cobra.Command{ var authConnectionsUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update a managed auth connection", - Long: "Update managed authentication settings like login URL, health checks, credential source, and proxy.", + Long: "Update managed authentication settings like login URL, health checks, CAPTCHA human intervention, credential source, and proxy.", Args: cobra.ExactArgs(1), RunE: runAuthConnectionsUpdate, } @@ -1314,6 +1329,11 @@ Canonical submissions (--field-value, --choice-id) are bound to the interaction they answer. The CLI reads the connection's current interaction ID for you; pass --interaction-id to pin the submission to a specific interaction instead. +When --hitl moves a login to AWAITING_HUMAN_INTERVENTION for an unsolved +CAPTCHA, complete the challenge at the Live View URL shown by +kernel auth connections get or kernel auth connections follow, then submit the +human_intervention choice with --choice-id to resume the login. + Examples: # Submit canonical field values from the connection's fields list kernel auth connections submit --field-value field_email=me@example.com --field-value field_password=secret @@ -1321,6 +1341,9 @@ Examples: # Answer a specific interaction (rejected if the flow has moved on) kernel auth connections submit --choice-id mfa_sms --interaction-id mai_abc123xyz + # Resume after completing an --hitl CAPTCHA in the live view + kernel auth connections submit --choice-id + # Submit legacy field values kernel auth connections submit --field username=myuser --field password=mypass @@ -1369,6 +1392,7 @@ func init() { authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks. Defaults to 3600 or your plan minimum, whichever is larger. The maximum is 86400; the minimum depends on your plan (Enterprise 300, Startup 1200, Hobbyist 3600, Free 21600)") authConnectionsCreateCmd.Flags().Bool("no-health-checks", false, "Disable periodic health checks (enabled by default)") authConnectionsCreateCmd.Flags().Bool("no-auto-reauth", false, "Mark expired sessions as NEEDS_AUTH instead of attempting automatic re-authentication (auto re-auth is enabled by default)") + authConnectionsCreateCmd.Flags().Bool("hitl", false, "Pause an unsolved login CAPTCHA for a person to complete in the live view, then resume with 'kernel auth connections submit --choice-id '; reauthentication never pauses (disabled by default)") authConnectionsCreateCmd.Flags().Bool("record-session", false, "Record browser sessions for this connection by default (useful for debugging)") authConnectionsCreateCmd.Flags().String("telemetry", "", "Configure telemetry for this connection's browser sessions (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") authConnectionsCreateCmd.Flags().String("telemetry-export-otlp", "", "Export this connection's captured telemetry over OTLP to one of the org's configured destinations, by ID or name; --telemetry-export-otlp=off disables export. Implies --telemetry=all when --telemetry is not set, since export requires capture") @@ -1401,6 +1425,7 @@ func init() { authConnectionsUpdateCmd.Flags().Bool("no-health-checks", false, "Disable periodic health checks") authConnectionsUpdateCmd.Flags().Bool("auto-reauth", false, "Permit automatic re-authentication when a health check detects an expired session") authConnectionsUpdateCmd.Flags().Bool("no-auto-reauth", false, "Mark expired sessions as NEEDS_AUTH instead of attempting automatic re-authentication") + authConnectionsUpdateCmd.Flags().Bool("hitl", false, "Set whether an unsolved login CAPTCHA pauses for a person to complete in the live view instead of failing; resume with 'kernel auth connections submit --choice-id '; use --hitl=false to disable; reauthentication never pauses") authConnectionsUpdateCmd.Flags().Bool("record-session", false, "Set whether browser sessions are recorded by default; use --record-session=false to disable") authConnectionsUpdateCmd.Flags().String("telemetry", "", "Update telemetry for future browser sessions: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection)") authConnectionsUpdateCmd.Flags().String("telemetry-export-otlp", "", "Update where future sessions export captured telemetry over OTLP, by destination ID or name; --telemetry-export-otlp=off disables export. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") @@ -1439,7 +1464,7 @@ func init() { // Submit flags addJSONOutputFlag(authConnectionsSubmitCmd) authConnectionsSubmitCmd.Flags().StringArray("field-value", []string{}, "Canonical field-id=value pair from the connection's `fields` list (repeatable)") - authConnectionsSubmitCmd.Flags().String("choice-id", "", "Canonical choice ID from the connection's `choices` list") + authConnectionsSubmitCmd.Flags().String("choice-id", "", "Canonical choice ID from the connection's `choices` list; after completing an --hitl CAPTCHA in the live view, use its human_intervention choice") authConnectionsSubmitCmd.Flags().String("interaction-id", "", "Canonical interaction ID the submitted values belong to; defaults to the connection's current interaction. Only valid with --field-value or --choice-id") authConnectionsSubmitCmd.Flags().StringArray("field", []string{}, "Legacy field name=value pair (repeatable); prefer --field-value") authConnectionsSubmitCmd.Flags().String("mfa-option-id", "", "MFA option ID if user selected an MFA method") @@ -1514,6 +1539,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { HealthCheckInterval: healthCheckInterval, NoHealthChecks: noHealthChecks, NoAutoReauth: noAutoReauth, + Hitl: readBoolFlag(cmd.Flags(), "hitl"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, @@ -1606,6 +1632,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { HealthCheckIntervalSet: cmd.Flags().Changed("health-check-interval"), HealthChecks: togglePair("health-checks", "no-health-checks"), AutoReauth: togglePair("auto-reauth", "no-auto-reauth"), + Hitl: readBoolFlag(cmd.Flags(), "hitl"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index 8e64f39f..0ebb70c3 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -106,6 +106,7 @@ func TestAuthConnectionsGet_PrintsSubmissionHints(t *testing.T) { return &kernel.ManagedAuth{ ID: id, Domain: "auth.leaseweb.com", + Hitl: true, ProfileName: "raf-leaseweb", Status: kernel.ManagedAuthStatusNeedsAuth, FlowStatus: kernel.ManagedAuthFlowStatusInProgress, @@ -136,6 +137,7 @@ func TestAuthConnectionsGet_PrintsSubmissionHints(t *testing.T) { assert.Contains(t, out, "Text message") assert.Contains(t, out, "Pending SSO Buttons") assert.Contains(t, out, "Continue with Google") + assert.Contains(t, out, "Human Intervention") } func TestAuthConnectionsGet_PrintsHealthCheckUnavailableReason(t *testing.T) { @@ -209,6 +211,11 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { Type: "mfa_method", MfaType: "totp", }, + { + ID: "captcha_complete", + Label: "I completed the challenge", + Type: "human_intervention", + }, }, }, nil }, @@ -228,6 +235,35 @@ func TestAuthConnectionsGet_PrintsCanonicalInputMetadata(t *testing.T) { assert.Contains(t, out, `mfa_method, sms, to=+1 ••• ••• 1234`) assert.Contains(t, out, `mfa_app (Use your authenticator app)`) assert.Contains(t, out, `mfa_method, totp`) + assert.Contains(t, out, `captcha_complete (I completed the challenge)`) + assert.Contains(t, out, `human_intervention`) +} + +func TestAuthConnectionsFollow_PrintsHumanInterventionLiveView(t *testing.T) { + outBuf := capturePtermOutput(t) + var event kernel.AuthConnectionFollowResponseUnion + require.NoError(t, json.Unmarshal([]byte(`{ + "event":"managed_auth_state", + "flow_status":"IN_PROGRESS", + "flow_step":"AWAITING_HUMAN_INTERVENTION", + "timestamp":"2030-01-01T00:00:00Z", + "interaction_id":"mai_hitl", + "live_view_url":"https://live.example/session", + "choices":[{"id":"captcha_complete","label":"I completed the challenge","type":"human_intervention"}] + }`), &event)) + fake := &FakeAuthConnectionService{ + FollowStreamingFunc: func(ctx context.Context, id string, opts ...option.RequestOption) *ssestream.Stream[kernel.AuthConnectionFollowResponseUnion] { + assert.Equal(t, "conn-1", id) + return makeStream([]kernel.AuthConnectionFollowResponseUnion{event}) + }, + } + + require.NoError(t, (AuthConnectionCmd{svc: fake}).Follow(context.Background(), AuthConnectionFollowInput{ID: "conn-1"})) + out := outBuf.String() + assert.Contains(t, out, "AWAITING_HUMAN_INTERVENTION") + assert.Contains(t, out, "Live View URL: https://live.example/session") + assert.Contains(t, out, "captcha_complete (I completed the challenge)") + assert.Contains(t, out, "human_intervention") } func TestAuthConnectionsGet_JSONOutputIncludesDiscoveredFields(t *testing.T) { @@ -515,6 +551,78 @@ func TestAuthConnectionsRegion_CommandFlags(t *testing.T) { } } +func TestAuthConnectionsHitl_CommandFlags(t *testing.T) { + wantTrue := true + wantFalse := false + tests := []struct { + name string + command *cobra.Command + method string + path string + args []string + want *bool + }{ + { + name: "create enables human intervention", + command: authConnectionsCreateCmd, + method: http.MethodPost, + path: "/auth/connections", + args: []string{"--domain", "example.com", "--profile-name", "work", "--hitl", "--output", "json"}, + want: &wantTrue, + }, + { + name: "create omits human intervention", + command: authConnectionsCreateCmd, + method: http.MethodPost, + path: "/auth/connections", + args: []string{"--domain", "example.com", "--profile-name", "work", "--output", "json"}, + }, + { + name: "update enables human intervention", + command: authConnectionsUpdateCmd, + method: http.MethodPatch, + path: "/auth/connections/conn-1", + args: []string{"conn-1", "--hitl", "--output", "json"}, + want: &wantTrue, + }, + { + name: "update disables human intervention", + command: authConnectionsUpdateCmd, + method: http.MethodPatch, + path: "/auth/connections/conn-1", + args: []string{"conn-1", "--hitl=false", "--output", "json"}, + want: &wantFalse, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := runAuthConnectionCommand(t, tt.command, tt.method, tt.path, tt.args...) + got, present := body["hitl"] + if tt.want == nil { + assert.False(t, present) + return + } + require.True(t, present) + assert.Equal(t, *tt.want, got) + }) + } + + createHelp := authConnectionsCreateCmd.Flags().Lookup("hitl").Usage + updateHelp := authConnectionsUpdateCmd.Flags().Lookup("hitl").Usage + for _, help := range []string{createHelp, updateHelp} { + assert.Contains(t, help, "CAPTCHA") + assert.Contains(t, help, "live view") + assert.Contains(t, help, "submit --choice-id ") + assert.Contains(t, help, "reauthentication never pauses") + } + assert.Contains(t, createHelp, "disabled by default") + assert.Contains(t, updateHelp, "--hitl=false") + assert.Contains(t, authConnectionsSubmitCmd.Long, "AWAITING_HUMAN_INTERVENTION") + assert.Contains(t, authConnectionsSubmitCmd.Long, "human_intervention") + assert.Contains(t, authConnectionsSubmitCmd.Long, "Live View") +} + func TestAuthConnectionsRegion_RejectsUnknownValue(t *testing.T) { c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} err := c.Create(context.Background(), AuthConnectionCreateInput{ diff --git a/go.mod b/go.mod index 1fc00bb4..f21eac20 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.136.0 + github.com/kernel/kernel-go-sdk v0.137.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 73c01e7b..6b20339d 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,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.136.0 h1:+CZDkqUurKE646Y/d7OEEaa3WbZ01WVODVq+W9CoUHk= -github.com/kernel/kernel-go-sdk v0.136.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= +github.com/kernel/kernel-go-sdk v0.137.0 h1:mlcB80Kq4BnDLTGSykLY4WMoKg2mYAUn9lQHM5OgPUk= +github.com/kernel/kernel-go-sdk v0.137.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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 d294c788a5f2ff620b838ddafb9827860102484c Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:13:18 +0000 Subject: [PATCH 8/8] Update Go SDK to 738369d524a3806832577f6793dde7eaf15c5725 and CLI coverage --- cmd/browser_pools.go | 1 + cmd/browser_pools_test.go | 4 ++++ cmd/browsers.go | 44 ++++++++++++++++++++++++++++++++++----- cmd/browsers_test.go | 17 ++++++++++++++- cmd/invoke.go | 29 +++++++++++++++++++++----- go.mod | 2 +- go.sum | 4 ++-- 7 files changed, 87 insertions(+), 14 deletions(-) diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 9ddce44b..d53ddfe3 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -749,6 +749,7 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu {"Property", "Value"}, {"Session ID", resp.SessionID}, } + tableData = appendBrowserLeasedAtRow(tableData, resp.LeasedAt) if resp.Name != "" { tableData = append(tableData, []string{"Name", resp.Name}) } diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index e942eddc..e3def395 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" @@ -85,6 +86,7 @@ func TestBrowserPoolsAcquire_WithNameAndTags(t *testing.T) { CdpWsURL: "ws://cdp-acq", Name: "lease-name", Tags: kernel.Tags{"env": "prod"}, + LeasedAt: time.Date(2032, 6, 7, 8, 9, 10, 0, time.UTC), }, nil }, } @@ -108,6 +110,8 @@ func TestBrowserPoolsAcquire_WithNameAndTags(t *testing.T) { assert.Contains(t, out, "lease-name") assert.Contains(t, out, "Tags") assert.Contains(t, out, "env=prod") + assert.Contains(t, out, "Leased At") + assert.Contains(t, out, "2032") } func TestBrowserPoolsList_ForwardsLimitOffset(t *testing.T) { diff --git a/cmd/browsers.go b/cmd/browsers.go index f1ee05a6..099548c8 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -707,8 +707,20 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { return nil } - // Prepare table data - headers := []string{"Browser ID", "Name", "Created At", "Region", "Profile", "Pool", "CDP WS URL", "Live View URL"} + // Prepare table data. leased_at is only present for leased pooled sessions, + // so keep the column out of the table when no returned browser has a lease. + showLeasedAt := false + for _, browser := range browsers { + if !browser.LeasedAt.IsZero() { + showLeasedAt = true + break + } + } + headers := []string{"Browser ID", "Name", "Created At"} + if showLeasedAt { + headers = append(headers, "Leased At") + } + headers = append(headers, "Region", "Profile", "Pool", "CDP WS URL", "Live View URL") showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all" if showDeletedAt { headers = append(headers, "Deleted At") @@ -734,12 +746,21 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { browser.SessionID, util.OrDash(browser.Name), util.FormatLocal(browser.CreatedAt), + } + if showLeasedAt { + leasedAt := "-" + if !browser.LeasedAt.IsZero() { + leasedAt = util.FormatLocal(browser.LeasedAt) + } + row = append(row, leasedAt) + } + row = append(row, util.OrDash(string(browser.Region)), profile, pool, truncateURL(browser.CdpWsURL, 50), truncateURL(browser.BrowserLiveViewURL, 50), - } + ) if showDeletedAt { deletedAt := "-" @@ -948,6 +969,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 = appendBrowserLeasedAtRow(tableData, browser.LeasedAt) tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}, []string{"Allowed Hosts", formatAllowedHosts(browser.Network)}, []string{"Proxy Routes", formatProxyRoutes(browser.Network)}) tableData = append(tableData, browserLocationRows(browser.Location)...) PrintTableNoPad(tableData, true) @@ -964,11 +986,19 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { return nil } -func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, profileSaveChanges bool, startURL, name string, tags kernel.Tags) { +func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, profileSaveChanges bool, startURL, name string, tags kernel.Tags, leasedAt time.Time) { tableData := buildBrowserTableData(sessionID, cdpURL, liveViewURL, profile, profileSaveChanges, startURL, name, tags) + tableData = appendBrowserLeasedAtRow(tableData, leasedAt) PrintTableNoPad(tableData, true) } +func appendBrowserLeasedAtRow(tableData pterm.TableData, leasedAt time.Time) pterm.TableData { + if leasedAt.IsZero() { + return tableData + } + return append(tableData, []string{"Leased At", util.FormatLocal(leasedAt)}) +} + // buildBrowserTableData creates a base table with common browser session fields. func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, profileSaveChanges bool, startURL, name string, tags kernel.Tags) pterm.TableData { tableData := pterm.TableData{ @@ -1074,6 +1104,7 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { // Append additional detailed fields tableData = append(tableData, []string{"Created At", util.FormatLocal(browser.CreatedAt)}) + tableData = appendBrowserLeasedAtRow(tableData, browser.LeasedAt) tableData = append(tableData, []string{"Region", util.OrDash(string(browser.Region))}) tableData = append(tableData, browserLocationRows(browser.Location)...) tableData = append(tableData, []string{"Timeout (seconds)", fmt.Sprintf("%d", browser.TimeoutSeconds)}) @@ -1421,6 +1452,9 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } pterm.Success.Printf("Updated browser %s\n", browser.SessionID) + if !browser.LeasedAt.IsZero() { + pterm.Info.Printf("Leased At: %s\n", util.FormatLocal(browser.LeasedAt)) + } if hasNameChange { pterm.Info.Printf("Name: %s\n", util.OrDash(browser.Name)) } @@ -3804,7 +3838,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if output == "json" { return util.PrintPrettyJSON(resp) } - printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.ProfileSaveChanges, resp.StartURL, resp.Name, resp.Tags) + printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.ProfileSaveChanges, resp.StartURL, resp.Name, resp.Tags, resp.LeasedAt) return nil } diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index b3f0b953..88fe26d4 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -465,12 +465,14 @@ func TestBrowsersList_PrintsTableWithRows(t *testing.T) { setupStdoutCapture(t) created := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + leased := time.Date(2030, 6, 7, 8, 9, 10, 0, time.UTC) rows := []kernel.BrowserListResponse{ { SessionID: "sess-1", CdpWsURL: "ws://cdp-1", BrowserLiveViewURL: "http://view-1", CreatedAt: created, + LeasedAt: leased, Region: kernel.BrowserListResponseRegionEuWest, }, { @@ -495,6 +497,8 @@ func TestBrowsersList_PrintsTableWithRows(t *testing.T) { assert.Contains(t, out, "sess-2") assert.Contains(t, out, "eu-west") assert.Contains(t, out, "us-east") + assert.Contains(t, out, "Leased At") + assert.Contains(t, out, "2030") } func TestBrowsersList_PrintsErrorOnFailure(t *testing.T) { @@ -1560,6 +1564,7 @@ func TestBrowsersGet_PrintsDetails(t *testing.T) { setupStdoutCapture(t) created := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + leased := time.Date(2031, 6, 7, 8, 9, 10, 0, time.UTC) fake := &FakeBrowsersService{ GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { return &kernel.BrowserGetResponse{ @@ -1567,6 +1572,7 @@ func TestBrowsersGet_PrintsDetails(t *testing.T) { CdpWsURL: "ws://cdp-url", BrowserLiveViewURL: "http://live-view", CreatedAt: created, + LeasedAt: leased, TimeoutSeconds: 300, Headless: false, Stealth: true, @@ -1592,6 +1598,8 @@ func TestBrowsersGet_PrintsDetails(t *testing.T) { assert.Contains(t, out, "my-profile") assert.Contains(t, out, "my-proxy (proxy-123)") assert.Contains(t, out, "eu-west") + assert.Contains(t, out, "Leased At") + assert.Contains(t, out, "2031") } func TestBrowsersGet_JSONOutput(t *testing.T) { @@ -3036,7 +3044,12 @@ func captureUpdateParams(t *testing.T) (*FakeBrowsersService, *kernel.BrowserUpd captured := &kernel.BrowserUpdateParams{} fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { *captured = body - return &kernel.BrowserUpdateResponse{SessionID: "session123", Name: body.Name.Value, Tags: body.Tags}, nil + return &kernel.BrowserUpdateResponse{ + SessionID: "session123", + Name: body.Name.Value, + Tags: body.Tags, + LeasedAt: time.Date(2033, 6, 7, 8, 9, 10, 0, time.UTC), + }, nil }} return fake, captured } @@ -3055,6 +3068,8 @@ func TestBrowsersUpdate_WithName_ForwardsParam(t *testing.T) { assert.NoError(t, err) assert.True(t, captured.Name.Valid()) assert.Equal(t, "new-name", captured.Name.Value) + assert.Contains(t, outBuf.String(), "Leased At") + assert.Contains(t, outBuf.String(), "2033") } func TestBrowsersUpdate_ClearName_SendsEmptyName(t *testing.T) { diff --git a/cmd/invoke.go b/cmd/invoke.go index 738c9d04..ea7f3d5e 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -590,7 +590,19 @@ func runInvocationBrowsers(cmd *cobra.Command, args []string) error { return nil } - table := pterm.TableData{{"Session ID", "Created At", "Region", "Headless", "Stealth", "Timeout", "CDP WS URL", "Live View URL"}} + showLeasedAt := false + for _, browser := range resp.Browsers { + if !browser.LeasedAt.IsZero() { + showLeasedAt = true + break + } + } + headers := []string{"Session ID", "Created At"} + if showLeasedAt { + headers = append(headers, "Leased At") + } + headers = append(headers, "Region", "Headless", "Stealth", "Timeout", "CDP WS URL", "Live View URL") + table := pterm.TableData{headers} for _, browser := range resp.Browsers { created := util.FormatLocal(browser.CreatedAt) @@ -599,16 +611,23 @@ func runInvocationBrowsers(cmd *cobra.Command, args []string) error { liveView = "-" } - table = append(table, []string{ - browser.SessionID, - created, + row := []string{browser.SessionID, created} + if showLeasedAt { + leasedAt := "-" + if !browser.LeasedAt.IsZero() { + leasedAt = util.FormatLocal(browser.LeasedAt) + } + row = append(row, leasedAt) + } + row = append(row, util.OrDash(browser.Region), fmt.Sprintf("%v", browser.Headless), fmt.Sprintf("%v", browser.Stealth), fmt.Sprintf("%d", browser.TimeoutSeconds), truncateURL(browser.CdpWsURL, 40), truncateURL(liveView, 40), - }) + ) + table = append(table, row) } pterm.Info.Printf("Browsers for invocation %s:\n", invocationID) diff --git a/go.mod b/go.mod index f21eac20..187e5a41 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.137.0 + github.com/kernel/kernel-go-sdk v0.138.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 6b20339d..dd014818 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,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.137.0 h1:mlcB80Kq4BnDLTGSykLY4WMoKg2mYAUn9lQHM5OgPUk= -github.com/kernel/kernel-go-sdk v0.137.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= +github.com/kernel/kernel-go-sdk v0.138.0 h1:jZg4WIDEV47annt1tc/ywwFfZRMd6yhLyMFG4+j20cs= +github.com/kernel/kernel-go-sdk v0.138.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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=